Vue3 Composable デザインパターン:シニア開発者のように再利用可能なロジックを構築する

前端工程

なぜ Composable が Vue3 アーキテクチャの中核なのか

2026年、Vue3 エコシステムは Composition API を完全に採用しています。Composable(コンポーザブル関数)はもはや「オプション」ではなく、保守性が高く、テスト可能で、再利用可能なロジックを構築する唯一の正統な方法です。Pinia、VueUse、Nuxt 3、Vuetify 3 — エコシステム全体が Composable を基盤として動いています。

特徴 Composable Mixin Renderless Component Custom Directive
ロジック再利用 ✅ 柔軟な合成 ⚠️ 暗黙的マージ ✅ 明示的渡し ❌ DOMのみ
型推論 ✅ 完全TS対応 ❌ 型消失 ⚠️ 手動アノテーション ❌ 型なし
名前衝突 ✅ 明示的分割 ❌ 暗黙的上書き ✅ props分離 ❌ グローバルリスク
テスタビリティ ✅ 純関数テスト ❌ コンテキスト結合 ⚠️ mount必要 ❌ DOM必要
Tree-shaking ✅ ネイティブ対応 ❌ 全体インポート ⚠️ コンポーネント粒度 ❌ グローバル登録
学習曲線 中程度 低い 高い 低い

結論:2026年、Composable はロジック再利用の最適解であり、例外はありません。


パターン1:useCounter — 状態管理パターン

解決する問題:コンポーネント間でシンプルなカウンターロジックを共有し、各コンポーネントでの ref と操作メソッドの重複定義を回避する。

import { ref, computed, type Ref, type ComputedRef } from 'vue'

interface UseCounterOptions {
  min?: number
  max?: number
  step?: number
  initialValue?: number
}

interface UseCounterReturn {
  count: Ref<number>
  isMin: ComputedRef<boolean>
  isMax: ComputedRef<boolean>
  increment: () => void
  decrement: () => void
  reset: () => void
  set: (value: number) => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { min = -Infinity, max = Infinity, step = 1, initialValue = 0 } = options

  const count = ref(initialValue)

  const isMin = computed(() => count.value <= min)
  const isMax = computed(() => count.value >= max)

  const clamp = (value: number): number => Math.min(max, Math.max(min, value))

  const increment = (): void => {
    count.value = clamp(count.value + step)
  }

  const decrement = (): void => {
    count.value = clamp(count.value - step)
  }

  const reset = (): void => {
    count.value = clamp(initialValue)
  }

  const set = (value: number): void => {
    count.value = clamp(value)
  }

  return { count, isMin, isMax, increment, decrement, reset, set }
}

使用例

<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'

const { count, isMin, isMax, increment, decrement } = useCounter({
  min: 0,
  max: 99,
  step: 1,
  initialValue: 0
})
</script>

<template>
  <button :disabled="isMin" @click="decrement">-</button>
  <span>{{ count }}</span>
  <button :disabled="isMax" @click="increment">+</button>
</template>

パターン2:useFetch / useAsyncData — 非同期データパターン

解決する問題:非同期リクエストの loading、error、data 状態を統一管理し、各コンポーネントでの try-catch-loading ボイラープレートを排除する。

import { ref, shallowRef, toValue, type MaybeRefOrGetter, type Ref } from 'vue'

interface UseFetchOptions<T> {
  immediate?: boolean
  initialData?: T
  refetch?: boolean
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
}

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  isLoading: Ref<boolean>
  isReady: Ref<boolean>
  execute: () => Promise<T | null>
  refresh: () => Promise<T | null>
}

export function useFetch<T>(
  url: MaybeRefOrGetter<string>,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const { immediate = true, initialData, onSuccess, onError } = options

  const data = shallowRef<T | null>(initialData ?? null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)
  const isReady = ref(false)

  const execute = async (): Promise<T | null> => {
    isLoading.value = true
    error.value = null

    try {
      const resolvedUrl = toValue(url)
      const response = await fetch(resolvedUrl)

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }

      data.value = await response.json()
      isReady.value = true
      onSuccess?.(data.value as T)
      return data.value as T
    } catch (err) {
      const errorInstance = err instanceof Error ? err : new Error(String(err))
      error.value = errorInstance
      onError?.(errorInstance)
      return null
    } finally {
      isLoading.value = false
    }
  }

  if (immediate) {
    execute()
  }

  return { data, error, isLoading, isReady, execute, refresh: execute }
}

使用例

<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

const { data: users, isLoading, error, refresh } = useFetch<User[]>('/api/users', {
  immediate: true,
  onSuccess: (data) => console.log(`Loaded ${data.length} users`)
})
</script>

<template>
  <div v-if="isLoading">読み込み中...</div>
  <div v-else-if="error">エラー:{{ error.message }}</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">{{ user.name }}</li>
  </ul>
  <button @click="refresh">更新</button>
</template>

パターン3:useFormValidation — フォームバリデーションパターン

解決する問題:フォームバリデーションロジックをコンポーネントから分離し、宣言的バリデーションルールと統一エラー管理を実現する。

import { ref, reactive, computed, type Ref } from 'vue'

type Validator = (value: any) => string | true
type FieldValidators = Record<string, Validator[]>

interface FormState {
  [key: string]: any
}

interface UseFormValidationReturn {
  formState: FormState
  errors: Ref<Record<string, string>>
  isValid: Ref<boolean>
  isDirty: Ref<boolean>
  validate: () => boolean
  validateField: (field: string) => boolean
  reset: () => void
  touched: Ref<Record<string, boolean>>
}

export function useFormValidation(
  initialState: FormState,
  validators: FieldValidators
): UseFormValidationReturn {
  const formState = reactive<FormState>({ ...initialState })
  const errors = ref<Record<string, string>>({})
  const touched = ref<Record<string, boolean>>({})

  const isDirty = computed(() => {
    return Object.keys(initialState).some(
      (key) => formState[key] !== initialState[key]
    )
  })

  const isValid = computed(() => {
    return Object.keys(errors.value).every(
      (key) => !errors.value[key]
    )
  })

  const validateField = (field: string): boolean => {
    const fieldValidators = validators[field]
    if (!fieldValidators) return true

    touched.value[field] = true

    for (const validator of fieldValidators) {
      const result = validator(formState[field])
      if (result !== true) {
        errors.value[field] = result
        return false
      }
    }

    errors.value[field] = ''
    return true
  }

  const validate = (): boolean => {
    let allValid = true
    for (const field of Object.keys(validators)) {
      if (!validateField(field)) {
        allValid = false
      }
    }
    return allValid
  }

  const reset = (): void => {
    Object.assign(formState, initialState)
    errors.value = {}
    touched.value = {}
  }

  return { formState, errors, isValid, isDirty, validate, validateField, reset, touched }
}

使用例

const { formState, errors, validate, isValid } = useFormValidation(
  { email: '', password: '' },
  {
    email: [
      (v: string) => !!v || 'メールアドレスは必須です',
      (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'メールアドレスの形式が正しくありません'
    ],
    password: [
      (v: string) => !!v || 'パスワードは必須です',
      (v: string) => v.length >= 8 || 'パスワードは8文字以上必要です'
    ]
  }
)

パターン4:useEventBus — イベントバスパターン

解決する問題:非親子コンポーネント間で型安全なイベント通信を実現し、Vue2の $on/$off グローバルイベントバスを置き換える。

import { onUnmounted, type Ref } from 'vue'

type EventMap = Record<string, any>

interface EventBus<T extends EventMap> {
  on: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
  off: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
  emit: <K extends keyof T & string>(event: K, payload: T[K]) => void
  once: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
}

export function createEventBus<T extends EventMap>(): EventBus<T> {
  const listeners = new Map<string, Set<Function>>()

  const on = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
    if (!listeners.has(event)) {
      listeners.set(event, new Set())
    }
    listeners.get(event)!.add(handler)
  }

  const off = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
    listeners.get(event)?.delete(handler)
  }

  const emit = <K extends keyof T & string>(event: K, payload: T[K]): void => {
    listeners.get(event)?.forEach((handler) => handler(payload))
  }

  const once = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
    const wrappedHandler = (payload: T[K]) => {
      handler(payload)
      off(event, wrappedHandler as any)
    }
    on(event, wrappedHandler as any)
  }

  return { on, off, emit, once }
}

export function useEventBus<T extends EventMap>(bus: EventBus<T>): EventBus<T> {
  const handlers = new Map<string, Function>()

  const scopedOn = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
    handlers.set(event, handler)
    bus.on(event, handler)
  }

  onUnmounted(() => {
    handlers.forEach((handler, event) => {
      bus.off(event as any, handler as any)
    })
  })

  return { ...bus, on: scopedOn as any, off: bus.off, emit: bus.emit, once: bus.once }
}

使用例

interface AppEvents {
  'user:login': { userId: string; token: string }
  'cart:update': { itemId: string; quantity: number }
  'notification:show': { message: string; type: 'success' | 'error' }
}

const bus = createEventBus<AppEvents>()

const { on, emit } = useEventBus(bus)
on('user:login', ({ userId }) => {
  console.log(`User ${userId} logged in`)
})

emit('user:login', { userId: '123', token: 'abc' })

パターン5:useDebounce — デバウンスパターン

解決する問題:デバウンスロジックを統一し、リアクティブな遅延時間と自動タイマークリーンアップをサポートする。

import { ref, unref, onUnmounted, type MaybeRef } from 'vue'

interface UseDebounceOptions {
  immediate?: boolean
}

interface UseDebounceReturn<T extends (...args: any[]) => any> {
  debouncedFn: T
  isPending: Ref<boolean>
  cancel: () => void
  flush: () => void
}

export function useDebounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: MaybeRef<number>,
  options: UseDebounceOptions = {}
): UseDebounceReturn<T> {
  const { immediate = false } = options

  let timeoutId: ReturnType<typeof setTimeout> | null = null
  let lastArgs: any[] | null = null

  const isPending = ref(false)

  const cancel = (): void => {
    if (timeoutId !== null) {
      clearTimeout(timeoutId)
      timeoutId = null
      isPending.value = false
      lastArgs = null
    }
  }

  const flush = (): void => {
    if (lastArgs !== null) {
      fn(...lastArgs)
      cancel()
    }
  }

  const debouncedFn = ((...args: any[]) => {
    lastArgs = args
    cancel()

    if (immediate && !timeoutId) {
      fn(...args)
      isPending.value = true
    }

    timeoutId = setTimeout(() => {
      if (!immediate) {
        fn(...args)
      }
      timeoutId = null
      isPending.value = false
      lastArgs = null
    }, unref(delay))
  }) as T

  onUnmounted(cancel)

  return { debouncedFn, isPending, cancel, flush }
}

使用例

const { debouncedFn: debouncedSearch } = useDebounce(
  (keyword: string) => {
    fetchResults(keyword)
  },
  300
)

debouncedSearch(inputValue)

パターン6:usePagination — ページネーションパターン

解決する問題:ページ番号計算、境界チェック、ページナビゲーションを備えたページネーションロジックを統一する。

import { ref, computed, toValue, type Ref, type ComputedRef, type MaybeRefOrGetter } from 'vue'

interface UsePaginationOptions {
  total: MaybeRefOrGetter<number>
  pageSize?: MaybeRefOrGetter<number>
  initialPage?: number
}

interface UsePaginationReturn {
  currentPage: Ref<number>
  totalPages: ComputedRef<number>
  isFirstPage: ComputedRef<boolean>
  isLastPage: ComputedRef<boolean>
  offset: ComputedRef<number>
  nextPage: () => void
  prevPage: () => void
  goToPage: (page: number) => void
  pageSize: ComputedRef<number>
}

export function usePagination(options: UsePaginationOptions): UsePaginationReturn {
  const { total, pageSize: pageSizeRef = 10, initialPage = 1 } = options

  const currentPage = ref(initialPage)

  const pageSize = computed(() => toValue(pageSizeRef))

  const totalPages = computed(() => {
    const totalItems = toValue(total)
    return Math.max(1, Math.ceil(totalItems / pageSize.value))
  })

  const isFirstPage = computed(() => currentPage.value <= 1)
  const isLastPage = computed(() => currentPage.value >= totalPages.value)
  const offset = computed(() => (currentPage.value - 1) * pageSize.value)

  const nextPage = (): void => {
    if (!isLastPage.value) {
      currentPage.value++
    }
  }

  const prevPage = (): void => {
    if (!isFirstPage.value) {
      currentPage.value--
    }
  }

  const goToPage = (page: number): void => {
    currentPage.value = Math.min(Math.max(1, page), totalPages.value)
  }

  return { currentPage, totalPages, isFirstPage, isLastPage, offset, nextPage, prevPage, goToPage, pageSize }
}

使用例

const { currentPage, totalPages, nextPage, prevPage, offset, isFirstPage, isLastPage } = usePagination({
  total: totalItems,
  pageSize: 20
})

const { data } = useFetch(() => `/api/items?offset=${offset.value}&limit=20`)

パターン7:useInfiniteScroll — 無限スクロールパターン

解決する問題:IntersectionObserver を使用した無限スクロールロジックを統一し、自動データ読み込みを実現する。

import { ref, onMounted, onUnmounted, type Ref } from 'vue'

interface UseInfiniteScrollOptions {
  target: Ref<HTMLElement | null>
  onLoadMore: () => Promise<void>
  threshold?: number
  rootMargin?: string
  disabled?: Ref<boolean>
}

interface UseInfiniteScrollReturn {
  isLoadingMore: Ref<boolean>
  isFinished: Ref<boolean>
  reset: () => void
}

export function useInfiniteScroll(options: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
  const {
    target,
    onLoadMore,
    threshold = 0.1,
    rootMargin = '100px',
    disabled = ref(false)
  } = options

  const isLoadingMore = ref(false)
  const isFinished = ref(false)
  let observer: IntersectionObserver | null = null

  const handleIntersect = async (entries: IntersectionObserverEntry[]): Promise<void> => {
    const [entry] = entries

    if (!entry.isIntersecting || isLoadingMore.value || isFinished.value || disabled.value) {
      return
    }

    isLoadingMore.value = true
    try {
      await onLoadMore()
    } catch {
      isFinished.value = true
    } finally {
      isLoadingMore.value = false
    }
  }

  const setupObserver = (): void => {
    if (!target.value) return

    observer = new IntersectionObserver(handleIntersect, {
      threshold,
      rootMargin
    })

    observer.observe(target.value)
  }

  const reset = (): void => {
    isFinished.value = false
    observer?.disconnect()
    setupObserver()
  }

  onMounted(setupObserver)
  onUnmounted(() => observer?.disconnect())

  return { isLoadingMore, isFinished, reset }
}

使用例

<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'

const sentinel = ref<HTMLElement | null>(null)
const items = ref<Item[]>([])

const { isLoadingMore } = useInfiniteScroll({
  target: sentinel,
  onLoadMore: async () => {
    const newItems = await fetchMoreItems(items.value.length)
    if (newItems.length === 0) return
    items.value.push(...newItems)
  }
})
</script>

<template>
  <div v-for="item in items" :key="item.id">{{ item.name }}</div>
  <div ref="sentinel" />
  <div v-if="isLoadingMore">さらに読み込み中...</div>
</template>

Vitest で Composable をテストする

Composable テストの核心原則:setup コンテキスト内で実行する@vue/test-utilswithSetup パターンまたは手動ラッパーを使用する。

import { describe, it, expect, vi } from 'vitest'
import { createApp } from 'vue'
import { useCounter } from '@/composables/useCounter'
import { useFetch } from '@/composables/useFetch'
import { useFormValidation } from '@/composables/useFormValidation'

export function withSetup<T>(composable: () => T): [T, () => void] {
  let result: T
  const app = createApp({
    setup() {
      result = composable()
      return () => null
    }
  })
  const mountPoint = document.createElement('div')
  app.mount(mountPoint)
  const unmount = () => app.unmount()
  return [result!, unmount]
}

describe('useCounter', () => {
  it('正しく初期化される', () => {
    const [counter, unmount] = withSetup(() => useCounter({ initialValue: 5 }))
    expect(counter.count.value).toBe(5)
    unmount()
  })

  it('increment と decrement をサポートする', () => {
    const [counter, unmount] = withSetup(() => useCounter({ min: 0, max: 10 }))
    counter.increment()
    expect(counter.count.value).toBe(1)
    counter.decrement()
    expect(counter.count.value).toBe(0)
    counter.decrement()
    expect(counter.count.value).toBe(0)
    unmount()
  })
})

describe('useFetch', () => {
  it('読み込み状態を正しく処理する', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
      new Response(JSON.stringify({ id: 1 }), { status: 200 })
    )

    const [fetchResult, unmount] = withSetup(() =>
      useFetch<{ id: number }>('/api/test', { immediate: false })
    )

    const promise = fetchResult.execute()
    expect(fetchResult.isLoading.value).toBe(true)
    await promise
    expect(fetchResult.isLoading.value).toBe(false)
    expect(fetchResult.data.value).toEqual({ id: 1 })
    unmount()
  })
})

describe('useFormValidation', () => {
  it('無効な入力を検出する', () => {
    const [form, unmount] = withSetup(() =>
      useFormValidation(
        { email: '' },
        { email: [(v: string) => !!v || '必須'] }
      )
    )
    form.validate()
    expect(form.errors.value.email).toBe('必須')
    unmount()
  })
})

アンチパターン:避けるべきパターン

アンチパターン1:Composable で props を直接変更する

// ❌ 悪い例:props の直接変更
export function useBadExample(props: { value: number }) {
  const doubled = computed(() => props.value * 2)
  const setDoubled = (val: number) => {
    props.value = val / 2 // ランタイム警告!Props は読み取り専用
  }
  return { doubled, setDoubled }
}

// ✅ 良い例:emit で親に通知
export function useGoodExample(props: { value: number }, emit: (e: 'update:value', val: number) => void) {
  const doubled = computed(() => props.value * 2)
  const setDoubled = (val: number) => {
    emit('update:value', val / 2)
  }
  return { doubled, setDoubled }
}

アンチパターン2:グローバル副作用をクリーンアップなしで登録する

// ❌ 悪い例:クリーンアップなし
export function useBadListener() {
  window.addEventListener('resize', handleResize) // アンマウント後も実行される
}

// ✅ 良い例:onUnmounted でクリーンアップ
export function useGoodListener() {
  const handleResize = () => { /* ... */ }
  window.addEventListener('resize', handleResize)
  onUnmounted(() => window.removeEventListener('resize', handleResize))
}

アンチパターン3:ref.value を ref 自体の代わりに返す

// ❌ 悪い例:.value を返す、リアクティビティを失う
export function useBadReturn() {
  const count = ref(0)
  return { count: count.value } // リアクティブではない!
}

// ✅ 良い例:ref 自体を返す
export function useGoodReturn() {
  const count = ref(0)
  return { count } // リアクティビティを維持
}

アンチパターン4:Composable で this を使用する

// ❌ 悪い例:Composable に this はない
export function useBadThis() {
  this.count++ // TypeError: Cannot read property 'count' of undefined
}

// ✅ 良い例:クロージャ変数を使用
export function useGoodClosure() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

5つのよくある落とし穴と解決策

落とし穴 症状 解決策
setup 外で Composable を呼び出す onUnmounted is not called エラー setup() または <script setup> 内で呼び出す、または withSetup テストヘルパーを使用
分割代入でリアクティビティを失う データは更新されるがビューが更新されない toRefs() で分割代入:const { count } = toRefs(useCounter())
共有状態の意図しない汚染 複数のコンポーネントが同じ ref を共有 Composable 呼び出しごとに新しい ref を作成、またはファクトリパターンを使用
非同期操作がキャンセルされない アンマウント後もコールバックが実行される AbortController を使用し onUnmounted でキャンセル
watch で古い DOM にアクセス nextTick 前に DOM 操作 watch コールバック内で await nextTick() 後に DOM 操作

10のエラートラブルシューティングチェックリスト

  1. isRef(x) is false:Composable の返り値で誤って .value を使用していないか確認 — 常に ref 自体を返す
  2. Cannot read property of null in onMounted:SSR 中は DOM が存在しない — setup で直接 DOM にアクセスせず onMounted を使用
  3. メモリリークsetIntervaladdEventListenerwatchonUnmounted でクリーンアップされているか確認
  4. Uncaught TypeError: composable is not a function:インポートパスとデフォルト/名前付きエクスポートの一致を確認
  5. onUnmounted is not called:Composable が setup コンテキスト外で呼び出されている — コンポーネント setup 内で実行すること
  6. computed が更新されない:依存する ref が正しく渡されていない、または shallowRef の誤用で深いプロパティ変更がトリガーされない
  7. watch がトリガーされない{ immediate: true } が必要か確認、または watch ソースが getter 関数か確認
  8. SSR hydration mismatch:Composable 内で window/document などのブラウザ API を使用 — onMounted で遅延させるか import.meta.env.SSR で判定
  9. TypeScript 型推論の失敗:Composable の戻り値型が明示的に宣言されていない — interface UseXxxReturn で戻り値型を明示定義
  10. 循環依存:Composable A が B を呼び出し、B が A を呼び出す — 単方向依存にリファクタリングするか、共有ロジックを3番目の Composable に抽出

Vue3 Composable vs React Hooks 比較

比較次元 Vue3 Composable React Hooks
実行タイミング setup で1回のみ実行 毎レンダーで実行
依存関係の宣言 自動追跡、宣言不要 手動 deps 配列
クロージャの罠 なし(setup は1回のみ実行) あり(stale closure 問題)
条件付き呼び出し ✅ 許可 ❌ 不許可(Hooks のルール)
戻り値 ref/reactive(リアクティブ) 普通の値(useState が必要)
ライフサイクル onMounted/onUnmounted useEffect クリーンアップ関数
パフォーマンス最適化 computed 自動キャッシュ useMemo 手動キャッシュ
学習曲線 中程度(リアクティビティシステム) 高い(クロージャ+deps+ルール)
型安全性 ✅ ネイティブ対応 ✅ ネイティブ対応
エコシステムツール VueUse / Pinia ahooks / SWR

核心的な違い:Vue3 Composable には React Hooks のクロージャの罠と依存配列の問題が存在しません。これは Vue3 のリアクティビティシステムの天然的優位性です。


おすすめツール

Composable 開発時に生産性を大幅に向上させるオンラインツール:


まとめ:Vue3 Composable は2026年のフロントエンドロジック再利用の究極のソリューションです。7つのデザインパターン(状態管理、非同期データ、フォームバリデーション、イベントバス、デバウンス、ページネーション、無限スクロール)をマスターし、Vitest テストとアンチパターン回避を組み合わせれば、シニア開発者のように保守性が高く、テスト可能で、再利用可能な Vue3 アプリケーションを構築できます。覚えておいてください:優れた Composable は純粋なロジック単位であり、UI に関心がなく、コンポーネントに関心がなく、状態と振る舞いの合成のみに関心があります。

ブラウザローカルツールを無料で試す →

#Vue3 Composable#组合式函数#逻辑复用#设计模式#2026