Composables - 状態を持つロジックの再利用

ロードマップ: Vue.js学習ロードマップ

What(何についてか)

Composable は、Vue の Composition API を使って stateful logic をカプセル化し、再利用する関数 である。 単なる utility 関数と異なり、内部で ref() による状態管理、watchEffect() による反応、onMounted() / onUnmounted() による副作用の開始と破棄を扱える。

代表的な用途は以下である。

  • ブラウザイベント購読(例: マウス座標、画面サイズ、オンライン状態)
  • 非同期データ取得(loading / success / error 状態管理)
  • フォームやバリデーションの状態管理
  • 複数コンポーネントで繰り返される UI 非依存ロジックの共通化

Why(なぜ必要か)

コンポーネントに状態変化と副作用を含むロジックを直接書き続けると、UI 記述と振る舞い記述が混ざり、見通しが悪くなる。 Composable を使うと、ロジックを「意味のある振る舞い単位」でコンポーネント外へ抽出できる。

この方式の利点は以下である。

  • コンポーネントを UI 表現に集中させられる
  • 同じ stateful logic を複数箇所で再利用できる
  • 責務の分離により、変更時の影響範囲を狭められる
  • 小さな composable を組み合わせて大きな振る舞いを構成できる

Vue 3 において composable が推奨される理由は、過去の mixins よりも依存関係が明示的で、renderless component よりも軽量にロジックを共有できるためである。

How(どう動くか)

基本構造

Composable は慣習的に useXxx という名前を持つ。 内部で状態を作り、必要に応じて副作用を登録し、外に公開したい値だけを返す。

import { ref } from 'vue'
 
export function useCounter() {
  const count = ref(0)
 
  const increment = () => {
    count.value += 1
  }
 
  return { count, increment }
}

呼び出し側は <script setup> または setup() で使う。

<script setup lang="ts">
import { useCounter } from './useCounter'
 
const { count, increment } = useCounter()
</script>

Mouse Tracker 例

マウス座標追跡のように、時間とともに変化する状態と DOM イベント購読は composable に向いている。

import { ref, onMounted, onUnmounted } from 'vue'
 
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
 
  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }
 
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))
 
  return { x, y }
}

この例の要点は、状態管理・イベント購読・クリーンアップを 1 つの API に閉じ込めている点にある。 呼び出し側コンポーネントは xy を受け取るだけでよい。

Composable のネスト

Composable は他の composable を内部で利用できる。 これにより、ロジックを責務ごとにさらに細かく分解できる。

import { onMounted, onUnmounted } from 'vue'
 
export function useEventListener(
  target: Window,
  event: string,
  callback: EventListener
) {
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}
import { ref } from 'vue'
import { useEventListener } from './useEventListener'
 
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
 
  useEventListener(window, 'mousemove', (event) => {
    const mouseEvent = event as MouseEvent
    x.value = mouseEvent.pageX
    y.value = mouseEvent.pageY
  })
 
  return { x, y }
}

この分割により、useEventListener() はイベント購読責務だけを担当し、useMouse() はマウス座標という意味を持つ状態だけを担当する。

非同期状態管理

Composable は API fetch のような非同期処理にも適用できる。 重要なのは、単なる fetch 呼び出しではなく、loading / success / error という 状態遷移パターン を再利用する点である。

import { ref, watchEffect, toValue, type Ref } from 'vue'
 
type MaybeRefOrGetter<T> = T | Ref<T> | (() => T)
 
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(true)
 
  const fetchData = async () => {
    data.value = null
    error.value = null
    loading.value = true
 
    try {
      const response = await fetch(toValue(url))
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = (await response.json()) as T
    } catch (err) {
      error.value = err instanceof Error ? err : new Error(String(err))
    } finally {
      loading.value = false
    }
  }
 
  watchEffect(() => {
    fetchData()
  })
 
  return { data, error, loading }
}

toValue() を使うことで、文字列・Ref<string>・getter 関数のいずれでも受けられる。 この設計により、props.id や他の reactive state の変化に応じて composable 側の副作用を再実行できる。

設計上の重要原則

返り値は plain object + refs

Composable の返り値は、reactive() で包んだオブジェクトではなく、plain object に refs を載せて返す設計が推奨される。

return { x, y, loading, error }

この形であれば呼び出し側で分割代入しても reactivity を失わない。

const { x, y } = useMouse()

一方で、reactive() オブジェクトを返して分割代入すると、各プロパティは単なる値になり、reactive な接続が切れる可能性がある。

reactive input を受けられるようにする

Composable の引数を固定値だけにすると、再利用性が低い。 実務では以下をそのまま受け取れるように設計すると使いやすい。

  • 生の値
  • ref
  • getter 関数

toValue() や getter パターンを使うと、呼び出し側のコンポーネントで不要な watcher を増やさずに済む。

setup 文脈で同期的に呼ぶ

Composable は通常の関数のように見えるが、内部で Vue の lifecycle hook や watcher を使う場合、コンポーネントの setup 文脈で同期的に呼ばれる必要がある

安全な呼び出し場所は以下である。

  • <script setup> のトップレベル
  • setup() 関数の同期処理中

setTimeout() やイベントハンドラの中で初めて lifecycle 付き composable を呼ぶ設計は避ける。 所有コンポーネントとの紐付けや cleanup が不明瞭になるためである。

Composition API における位置づけ

Composable は、Vue 3 におけるロジック再利用の第一選択肢である。 ただし、再利用したい対象によって使い分ける。

flowchart TD
    A["再利用したい対象は何か"] --> B["UI レイアウトも含む"]
    A --> C["ロジックだけ"]
    A --> D["アプリ全体で共有する状態"]
    B --> E["Component を使う"]
    C --> F["Composable を使う"]
    D --> G["State Management を検討する"]

Options API → Composition API 差分(補足)

項目Options API(旧)Composition API(新)
ロジック再利用mixins が中心composable が中心
値の由来instance に暗黙注入されやすいuseXxx() の返り値として明示的
名前衝突mixin 間で起こりやすい分割代入時に rename 可能
ロジック合成暗黙結合しやすい関数呼び出しで明示的に合成
Setup 文脈不要composable は setup() / <script setup> 内で使う

他手法との比較

vs Mixins

mixins は便利だが、値の出どころが不明瞭になりやすく、namespace collision や暗黙の結合が起きやすい。 Composable は通常の関数として呼び出し、返り値を明示的に受け取るため、依存関係が追いやすい。

vs Renderless Components

Renderless Component は slot を使ってロジックを共有するが、追加のコンポーネントインスタンスが必要になる。 Composable はロジックのみを共有したい場合に、より軽量である。

vs React Hooks

命名や発想は似ているが、Vue composable は Vue の fine-grained reactivity を前提としている。 React Hooks の再実行モデルと見た目が似ていても、依存追跡と更新の粒度は異なる。

Key Concepts

用語説明
ComposableComposition API を使って stateful logic を再利用する関数
Stateful Logic時間経過やイベントによって変化する状態を含むロジック
useXxxcomposable であることを示す慣習的な命名
Reactive Inputref や getter のように変化を追跡できる入力
toValue()値 / ref / getter を統一的に値へ正規化する API
watchEffect()内部で参照した reactive dependency を自動追跡して再実行する API
Plain Object Returnrefs をそのまま返し、分割代入しても reactivity を保てる返却方式
Composable Nestingcomposable の中から別の composable を呼び、責務を細分化して再合成する設計

実務での判断軸

  • 画面の見た目と振る舞いをまとめて再利用したいなら component を使う
  • 状態を持つロジックだけを切り出したいなら composable を使う
  • 複数画面やアプリ全体で共有する状態が主題なら state management を検討する

Composable は万能ではないが、Vue 3 において「コンポーネントからロジックを剥がす第一手」として非常に強力である。