Vue 3 コンポーザブル上級ガイド:再利用可能なパターンと技術面接の質問 2026

Vue 3の高度なコンポーザブルパターンを網羅的に解説。非同期処理、依存性注入、フォームバリデーション、テスト手法、2026年の技術面接で頻出する質問と回答を紹介します。

Vue 3の高度なコンポーザブルパターンと合成・依存性注入のダイアグラム

コンポーザブルは、Vue 3におけるロジック再利用の根幹を成す仕組みです。Composition APIの普及が進んだ現在、堅牢でタイプセーフかつテスト可能なコンポーザブルを設計する能力は、技術面接においてシニアレベルの開発者と中級者を明確に区別する指標となっています。2026年においては、単なるrefやcomputedの使い方にとどまらず、ライフサイクルの精密な管理、リアクティブ関数の合成、そして複雑なアプリケーションのモジュラーアーキテクチャまでが求められます。

本記事では、Vue 3のコンポーザブルにおける上級パターンを体系的に解説します。適切に構造化されたコンポーザブルの設計原則から、非同期処理、依存性注入、テスト戦略に至るまで、本番環境および技術面接の両方で即座に活用できる具体的な実装例を提示します。

コンポーザブルとは

コンポーザブルとは、VueのComposition APIを活用してステートフルなロジックをカプセル化し再利用するための関数です。慣例としてuseプレフィックスが付けられます(例:useCounter、useFetchData)。Vue 2のミックスインとは異なり、コンポーザブルは明示的な型付け、名前衝突の回避、依存関係の透明性を提供します。

適切に構造化されたコンポーザブルの設計原則

本番品質のコンポーザブルは、いくつかのアーキテクチャ原則に従います。具体的には、入出力の厳密な型定義、責務の分離、予測可能なインターフェースです。以下のuseCounterコンポーザブルは、これらの基本規約を示しています。

useCounter.tstypescript
import { ref, computed, type Ref } from 'vue'

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

interface UseCounterReturn {
  count: Ref<number>
  doubled: Ref<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

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

  const count = ref(initialValue)
  const doubled = computed(() => count.value * 2)

  function increment() {
    if (count.value < max) count.value++
  }

  function decrement() {
    if (count.value > min) count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, doubled, increment, decrement, reset }
}

この実装にはいくつかの注目すべき設計要素があります。UseCounterOptionsインターフェースは設定項目に対する明確な契約を定義し、UseCounterReturnはコンポーザブルが公開する内容を正確にドキュメント化しています。関数本体でのデフォルト値付きデストラクチャリングにより、明示的な設定がなくても予測可能な動作が保証されます。

名前付きオブジェクト(配列ではなく)を返すことで、呼び出し側はデストラクチャリングによって必要なプロパティのみを選択できます。この規約はVueエコシステム全体で採用されており、呼び出し元コードの可読性と保守性を向上させます。

minとmaxの境界値は、本番環境のコンポーザブルで頻出するパターンを示しています。ビジネスロジックの制約をリアクティブロジック内で直接検証することで、コンシューマーコンポーネントにルールが分散するのを防止します。

非同期コンポーザブルとエラーハンドリング

HTTPリクエストの管理は、コンポーザブルの最も一般的なユースケースの一つです。適切に設計された非同期コンポーザブルは、リクエストの全ライフサイクルを管理する必要があります。すなわち、ローディング状態、成功、エラー、そしてキャンセルです。以下のパターンは、自動的なリアクティビティのためにwatchEffectを、リクエストの適切なキャンセルのためにAbortControllerを統合しています。

useFetchData.tstypescript
import { ref, watchEffect, onUnmounted, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

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

export function useFetchData<T>(
  url: MaybeRefOrGetter<string>
): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const isLoading = ref(false)
  let abortController: AbortController | null = null

  async function fetchData() {
    // Cancel any in-flight request
    abortController?.abort()
    abortController = new AbortController()

    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url), {
        signal: abortController.signal
      })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      isLoading.value = false
    }
  }

  // Re-fetch when URL changes reactively
  watchEffect(() => {
    fetchData()
  })

  onUnmounted(() => abortController?.abort())

  return { data, error, isLoading, refresh: fetchData }
}

urlパラメータにMaybeRefOrGetter<string>を使用することで、最大限の柔軟性が確保されます。呼び出し側は静的な文字列、ref、またはゲッター関数のいずれも渡すことができます。toValue()関数は、生の値であってもリアクティブな値であっても、基底の型を自動的に解決します。

AbortControllerによるキャンセル機構は、前のリクエストが完了する前に新しいリクエストが発行された際の競合状態(レースコンディション)を防止します。onUnmountedフックはコンポーネント破棄時に進行中のリクエストを確実にクリーンアップし、メモリリークやアンマウント済みコンポーネントへの状態更新を回避します。

戻り値として公開されるrefreshメソッドにより、コンシューマーコンポーネントはデータの再読み込みを手動でトリガーできます。「更新」ボタンのようなユーザーアクションには不可欠なパターンです。

コンポーザブル内のライフサイクルフック

ライフサイクルフック(onMounted、onUnmountedなど)は、コンポーザブル本体で同期的に呼び出す必要があります。非同期コールバックやsetTimeout内で呼び出してはなりません。Vueはこれらのフックを呼び出し時点でアクティブなコンポーネントインスタンスに関連付けます。遅延呼び出しはサイレントエラーを引き起こしたり、誤ったコンポーネントにフックを紐付けたりする可能性があります。

コンポーザブルの合成

コンポーザブルの真の力は、相互に組み合わせる能力にあります。上位レベルのコンポーザブルが複数の特化したコンポーザブルを統合し、責務の明確な分離を維持しながら複雑な機能を構築できます。以下のパターンは、デバウンス付き検索、ページネーション、データ取得を組み合わせています。

usePaginatedSearch.tstypescript
import { ref, computed, watch, type Ref } from 'vue'
import { useFetchData } from './useFetchData'
import { useDebouncedRef } from './useDebouncedRef'

interface UsePaginatedSearchReturn<T> {
  query: Ref<string>
  page: Ref<number>
  results: Ref<T[] | null>
  totalPages: Ref<number>
  isLoading: Ref<boolean>
  error: Ref<string | null>
  nextPage: () => void
  prevPage: () => void
}

export function usePaginatedSearch<T>(
  baseUrl: string,
  perPage = 20
): UsePaginatedSearchReturn<T> {
  const query = useDebouncedRef('', 300)
  const page = ref(1)
  const totalPages = ref(1)

  const apiUrl = computed(
    () => `${baseUrl}?q=${encodeURIComponent(query.value)}&page=${page.value}&limit=${perPage}`
  )

  const { data, error, isLoading } = useFetchData<{ items: T[]; total: number }>(apiUrl)

  const results = computed(() => data.value?.items ?? null)

  watch(data, (response) => {
    if (response) {
      totalPages.value = Math.ceil(response.total / perPage)
    }
  })

  // Reset to page 1 when query changes
  watch(query, () => { page.value = 1 })

  function nextPage() {
    if (page.value < totalPages.value) page.value++
  }

  function prevPage() {
    if (page.value > 1) page.value--
  }

  return { query, page, results, totalPages, isLoading, error, nextPage, prevPage }
}

このコンポーザブルは、いくつかの高度な合成テクニックを実証しています。useDebouncedRefはタイミング制御ロジックをカプセル化し、キーストロークごとにAPIへ過剰なリクエストが送信されるのを防ぎます。computedのapiUrlはクエリまたはページが変更されると自動的にURLを再構築し、useFetchDataを通じて新たなデータ取得をトリガーします。

queryに対するwatchは、新しい検索のたびにページを1にリセットします。ユーザーが期待する動作でありながら、素朴な実装では見落とされがちなポイントです。この詳細は、ロジックをコンポーネントに分散させるのではなく、コンポーザブルにカプセル化する意義を示しています。

コンポーザブルの合成は単一責任の原則に従います。useDebouncedRefはデバウンスを管理し、useFetchDataはリクエスト・レスポンスのサイクルを管理し、usePaginatedSearchが全体を統括します。このアーキテクチャにより、各レイヤーの単体テストを独立して実行できます。

Vue.js / Nuxt.jsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

provide/injectによる依存性注入

コンポーネントツリー内で離れた位置にあるコンポーネント間で状態を共有する場合、Vueのprovide/injectシステムはpropsのバケツリレー(prop drilling)に対するエレガントな代替手段を提供します。コンポーザブルはこの仕組みをカプセル化し、クリーンでタイプセーフなAPIを提供できます。

useTheme.tstypescript
import { provide, inject, ref, readonly, type InjectionKey, type Ref } from 'vue'

type Theme = 'light' | 'dark' | 'system'

interface ThemeContext {
  theme: Readonly<Ref<Theme>>
  setTheme: (t: Theme) => void
  resolvedTheme: Readonly<Ref<'light' | 'dark'>>
}

const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

export function provideTheme(initial: Theme = 'system') {
  const theme = ref<Theme>(initial)

  const resolvedTheme = computed<'light' | 'dark'>(() => {
    if (theme.value !== 'system') return theme.value
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  })

  function setTheme(t: Theme) {
    theme.value = t
  }

  const context: ThemeContext = {
    theme: readonly(theme),
    setTheme,
    resolvedTheme: readonly(resolvedTheme)
  }

  provide(ThemeKey, context)
  return context
}

export function useTheme(): ThemeContext {
  const context = inject(ThemeKey)
  if (!context) {
    throw new Error('useTheme() requires a parent component to call provideTheme()')
  }
  return context
}

Symbolを用いた型付きInjectionKeyは、インジェクションキーの一意性を保証し、inject呼び出し時に自動的な型推論を提供します。このパターンは、文字列ベースのキーに存在したキー衝突のリスクを排除します。

provideThemeとuseThemeの分離は、明確なアーキテクチャ境界を確立します。ルートコンポーネント(またはレイアウト)がprovideThemeを呼び出してコンテキストを初期化し、子孫コンポーネントがuseThemeを呼び出して消費します。公開されるrefにreadonlyを適用することで、コンシューマーからの意図しないミューテーションを防止します。

resolvedThemeのcomputedは頻出パターンを示しています。抽象的な設定値('system')をシステム環境設定に基づく具体的な値('light'または'dark')に変換するものです。この抽象化レベルにより、コンシューマーコンポーネントのロジックが大幅に簡素化されます。

フォームバリデーション用コンポーザブル

フォームバリデーションは、コンポーザブルへのカプセル化から大きな恩恵を受ける複雑なユースケースです。以下のパターンは、ルールベースの宣言的バリデーション、エラーのリアクティブ管理、グローバルな有効性状態を提供します。

useFormValidation.tstypescript
import { reactive, computed, type UnwrapNestedRefs } from 'vue'

type ValidationRule<T> = (value: T) => string | true
type FieldRules<T> = { [K in keyof T]?: ValidationRule<T[K]>[] }

interface UseFormReturn<T extends Record<string, any>> {
  fields: UnwrapNestedRefs<T>
  errors: Record<keyof T, string>
  isValid: Ref<boolean>
  validate: () => boolean
  resetErrors: () => void
}

export function useFormValidation<T extends Record<string, any>>(
  initialValues: T,
  rules: FieldRules<T>
): UseFormReturn<T> {
  const fields = reactive({ ...initialValues }) as UnwrapNestedRefs<T>

  const errors = reactive(
    Object.keys(initialValues).reduce(
      (acc, key) => ({ ...acc, [key]: '' }),
      {} as Record<keyof T, string>
    )
  )

  function validate(): boolean {
    let valid = true
    for (const key of Object.keys(rules) as (keyof T)[]) {
      const fieldRules = rules[key] || []
      errors[key] = '' as any
      for (const rule of fieldRules) {
        const result = rule(fields[key])
        if (result !== true) {
          errors[key] = result as any
          valid = false
          break // Stop at first error per field
        }
      }
    }
    return valid
  }

  function resetErrors() {
    for (const key of Object.keys(errors)) {
      (errors as any)[key] = ''
    }
  }

  const isValid = computed(() =>
    Object.values(errors).every((e) => e === '')
  )

  return { fields, errors, isValid, validate, resetErrors }
}

このコンポーザブルはfieldsオブジェクトにrefではなくreactiveを使用しています。これにより.valueなしでプロパティに直接アクセスできます。バリデーションルールは、成功時にtrueを、失敗時にエラーメッセージの文字列を返すという簡潔な規約に従います。break文によりフィールドごとに最初のエラーでバリデーションが停止し、エラーメッセージの蓄積を防ぎます。

FieldRules<T>型はTypeScriptのマップ型を活用し、ルールがフォームのフィールドに確実に対応することを保証します。このアプローチはコンパイル時に型エラーを検出し、本番環境のバグを削減します。

isValidのcomputedは、フォーム全体の状態を示すリアクティブなインジケータを提供します。送信ボタンの有効・無効の切り替えに、コンポーネント側で追加のロジックを必要としません。

コンポーザブルのテスト

コンポーザブルはリアクティビティシステムおよびライフサイクルフックに依存するため、正常に機能するにはアクティブなVueコンテキストが必要です。標準的なテクニックは、setup内でコンポーザブルをインスタンス化する最小限のラッパーコンポーネントを作成することです。VitestとVue Test Utilsが必要なツールを提供します。

useCounter.spec.tstypescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useCounter } from './useCounter'

function withSetup<T>(composable: () => T): { result: T; unmount: () => void } {
  let result!: T
  const wrapper = mount(
    defineComponent({
      setup() {
        result = composable()
        return () => h('div')
      }
    })
  )
  return { result, unmount: () => wrapper.unmount() }
}

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = withSetup(() => useCounter())
    expect(result.count.value).toBe(0)
  })

  it('respects min and max boundaries', () => {
    const { result } = withSetup(() =>
      useCounter({ min: 0, max: 3, initialValue: 3 })
    )
    result.increment()
    expect(result.count.value).toBe(3) // Capped at max

    result.count.value = 0
    result.decrement()
    expect(result.count.value).toBe(0) // Capped at min
  })

  it('computes doubled value reactively', () => {
    const { result } = withSetup(() => useCounter({ initialValue: 5 }))
    expect(result.doubled.value).toBe(10)
    result.increment()
    expect(result.doubled.value).toBe(12)
  })
})

ユーティリティ関数withSetupは、あらゆるコンポーザブルのテストに再利用可能なパターンです。唯一の役割がリアクティブコンテキスト内でコンポーザブルを実行することである最小限のコンポーネントをマウントします。unmountメソッドの返却により、onUnmountedフックのクリーンアップ動作のテストも可能になります。

テストは3つの本質的な側面をカバーしています。デフォルト値での初期化、ビジネス制約(min/maxの境界)の遵守、そして算出値のリアクティビティです。この網羅範囲は、本番コンポーザブルに期待される最低限のテストカバレッジです。

useFetchDataのような非同期コンポーザブルの場合、fetchのモックとflushPromises()を使用してアサーション前にPromiseの解決を待つ必要があります。このトピックは、Vueでの非同期テストの習熟度を評価する典型的な面接の質問です。

VueUse:リファレンスライブラリ

VueUseは、ブラウザインタラクション、センサー、アニメーション、リアクティブユーティリティをカバーする200以上のコンポーザブルを提供しています。カスタムコンポーザブルを開発する前に、VueUseが既に解決策を提供しているか確認することが推奨されます。ただし、本番環境でこれらのコンポーザブルを適応、拡張、デバッグするためには、基盤となるパターンの理解が不可欠です。

技術面接で頻出する質問

Vue 3のコンポーザブルは、2026年の技術面接において避けて通れないトピックです。以下の質問は、中級からシニアレベルまで、採用担当者が評価するポイントを網羅しています。

コンポーザブルとミックスインの違いは何ですか? Vue 2のミックスインはオプションをコンポーネントにマージするため、名前の衝突、データソースの不透明性、型付けの欠如を引き起こします。コンポーザブルは型付きの戻り値を通じて明示的なインターフェースを公開し、デストラクチャリングによるリネームを可能にし、依存関係を透明にします。保守性、テスト容易性、TypeScript互換性のすべてにおいてコンポーザブルが優れています。

コンポーザブル内の副作用はどのように管理すべきですか? すべての副作用(イベントリスナー、タイマー、WebSocketサブスクリプション)は、onUnmountedフックまたはwatchEffectのクリーンアップ機構で解除する必要があります。このクリーンアップを怠ると、メモリリークやコンポーネントの再マウント時の予期しない動作を引き起こします。

setup()の外でコンポーザブルを呼び出すことは可能ですか? できません。コンポーザブルはコンポーネントのsetup()関数内、または他のコンポーザブル内で同期的に呼び出す必要があります。このコンテキスト外での呼び出しは、Vueがライフサイクルフックとリアクティビティをコンポーネントインスタンスに関連付けることを妨げます。

コンポーザブルでグローバル状態を共有するにはどうすればよいですか? 主に2つのアプローチがあります。シングルトンパターンは、コンポーザブル関数の外(モジュールレベル)でリアクティブなインスタンスを作成し、すべてのコンシューマー間で共有します。provide/injectパターンは、コンポーネントツリーを利用して状態をコンテキスト的に伝播します。真にグローバルな状態にはシングルトン、サブツリーにスコープされた状態にはprovide/injectが適しています。

ライフサイクルフックを使用するコンポーザブルをどうテストしますか? 前述のwithSetupテクニックは、必要なVueコンテキストを提供する最小限のラッパーコンポーネントを作成します。onMountedのようなフックの場合、Vue Test Utilsを使用して実DOM(または仮想DOM)にコンポーネントをマウントする必要があります。ライフサイクルフックを使用しない純粋にリアクティブなコンポーザブルは、VueのeffectScopeでより簡潔にテストできます。

SSR環境での非同期コンポーザブルにはどのような戦略が必要ですか? SSRコンテキスト(NuxtまたはVueサーバーサイドレンダリング)では、非同期コンポーザブルに特別な配慮が必要です。onMountedはサーバーサイドでは実行されず、fetch呼び出しはフレームワークのSSR機構(NuxtのuseAsyncDataなど)を通じて管理する必要があります。コンポーザブルは実行環境を判別し、それに応じて動作を適応させる必要があります。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ

Vue 3のコンポーザブルは、ミックスインからの単なる構文的な進化ではありません。フロントエンドアプリケーションにおける再利用可能なロジックの構造化方法に関するパラダイムシフトを体現しています。その習得は、本番開発と技術面接の両方において大きな競争優位性となります。

重要なポイントは以下の通りです。

  • 厳密な型定義:オプションと戻り値に明示的なインターフェースを定義することで、信頼性と開発者体験が向上します
  • ライフサイクル管理:メモリリークを防ぐため、副作用は常にonUnmountedでクリーンアップする必要があります
  • 合成:モノリシックなコンポーザブルではなく、組み合わせ可能な特化したコンポーザブルを設計することが重要です
  • 精密なリアクティビティ:MaybeRefOrGetter、toValue、watchEffectを活用して柔軟性を最大化します
  • 依存性注入:コンポーネントツリー内の状態共有には、型付きInjectionKeyとprovide/injectを使用します
  • テスト容易性:withSetupテクニックは、各コンポーザブルを独立して検証するための最小限のリアクティブコンテキストを提供します
  • バリデーション:ビジネスルールをバリデーション用コンポーザブルにカプセル化することで、アプリケーション全体の一貫性が保証されます

堅牢なコンポーザブルを設計、合成、テストする能力は、2026年のVue市場においてシニアレベルの人材を識別する指標です。これらのパターンは、Vue 3およびNuxt 3の本番アプリケーションを支える技術基盤となります。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

今日のチャレンジ

Vue.js / Nuxt.js のバグを見つけられますか

実際のコード、隠れたバグ、1日1回。アカウントなしで試せます。

Anthony Fillion-Maillet

執筆

Anthony Fillion-Maillet

SharpSkill 創業者

10 年以上フルスタック開発に携わっています。SharpSkill を運営し、ここで公開される内容に責任を負っています。

2026年5月12日 更新

タグ

#vue
#composables
#composition-api
#interview
#deep-dive

共有

関連記事