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-utils の withSetup パターンまたは手動ラッパーを使用する。
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のエラートラブルシューティングチェックリスト
isRef(x) is false:Composable の返り値で誤って.valueを使用していないか確認 — 常に ref 自体を返すCannot read property of nullin onMounted:SSR 中は DOM が存在しない —setupで直接 DOM にアクセスせずonMountedを使用- メモリリーク:
setInterval、addEventListener、watchがonUnmountedでクリーンアップされているか確認 Uncaught TypeError: composable is not a function:インポートパスとデフォルト/名前付きエクスポートの一致を確認onUnmounted is not called:Composable が setup コンテキスト外で呼び出されている — コンポーネント setup 内で実行すること- computed が更新されない:依存する ref が正しく渡されていない、または
shallowRefの誤用で深いプロパティ変更がトリガーされない - watch がトリガーされない:
{ immediate: true }が必要か確認、または watch ソースが getter 関数か確認 - SSR hydration mismatch:Composable 内で
window/documentなどのブラウザ API を使用 —onMountedで遅延させるかimport.meta.env.SSRで判定 - TypeScript 型推論の失敗:Composable の戻り値型が明示的に宣言されていない —
interface UseXxxReturnで戻り値型を明示定義 - 循環依存: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 開発時に生産性を大幅に向上させるオンラインツール:
- JSON フォーマッター — Composable が返す JSON データをフォーマット、リアクティブ状態をデバッグ
- Base64 エンコーダー — API リクエストパラメータをエンコード、useFetch の認証トークンを処理
- ハッシュ計算ツール — データハッシュを計算、キャッシュキーの生成と変更検出を実装
まとめ:Vue3 Composable は2026年のフロントエンドロジック再利用の究極のソリューションです。7つのデザインパターン(状態管理、非同期データ、フォームバリデーション、イベントバス、デバウンス、ページネーション、無限スクロール)をマスターし、Vitest テストとアンチパターン回避を組み合わせれば、シニア開発者のように保守性が高く、テスト可能で、再利用可能な Vue3 アプリケーションを構築できます。覚えておいてください:優れた Composable は純粋なロジック単位であり、UI に関心がなく、コンポーネントに関心がなく、状態と振る舞いの合成のみに関心があります。
ブラウザローカルツールを無料で試す →