Vue 3 のカスタムコンポーザブル関数:useMouse、useFetch、useLocalStorage
コンポーザブル関数(フックとも呼ばれる)は、Vue 3のComposition APIにおける中核となる再利用パターンであり、「リアクティブなデータ+ビジネスロジック」を再利用可能な関数にカプセル化します。本質的には、ref、computed、watchなどで構成される「ロジックパッケージ」です。
Composablesを習得することは、保守性の高いVueアプリケーションを作成するための鍵となります。Composablesを使用することで、コンポーネントのコードをより簡潔にでき、ビジネスロジックをコンポーネント間で共有できるようになります。このレッスンでは、独自のComposableライブラリをゼロから構築する方法を学びます。
1. 学習内容
- 「コンポーザブル(フック)」とは何ですか?また、なぜそれが必要なのですか?
- 組み合わせ可能な命名規則
useXxx - 5つの実用的なコンポーザブル(useMouse / useLocalStorage / useFetch / useDebounce / useToggle)
- パラメータの受け取りと値の返却に関するベストプラクティス
- ライフサイクルフックのカプセル化
- コンポーネント間で共有されるコンポーザブル(composables/ ディレクトリ)
- VueUse ライブラリ(30以上のすぐに使えるコンポーザブル)
2. ショッピングカート用の「Get + デバウンス」ロジックが5回重複している
(1) 課題:検索ボックスが5つ、重複コードが5箇所
Aliceの管理画面には5つの検索入力フィールド(注文/商品/ユーザーなど)があり、それぞれにデータ取得とデバウンスのロジックが実装されていました:
// ❌ The "Flip" Version:5 component,5 Duplicate code
// OrdersSearch.vue
let timer = null
const searchQuery = ref('')
const results = ref([])
watch(searchQuery, (val) => {
if (timer) clearTimeout(timer)
timer = setTimeout(async () => {
const res = await fetch(`/api/orders?q=${val}`)
results.value = await res.json()
}, 300)
})
// ProductsSearch.vue - Exactly the same logic(Change only URL)
// UsersSearch.vue - Exactly the same logic(Change only URL)
// ... And also 2 more ...
合計:50行 × 5 = 250行の重複コード。バグの修正には、5か所の変更が必要です。
プロダクトマネージャーのチャーリーが、新たな要件を追加しました:
「アリス、デバウンスを300msから500msに変更して。それと、最小文字数のチェックを追加して(3文字以上の場合のみ検索するように)。」
アリスは5つのファイルを編集しなければならない。バグが発生しやすい。
(2) Vue Composable ソリューション:1 useSearch、5か所で再利用
// composables/useSearch.js - 1 Composable
import { ref, watch } from 'vue'
export function useSearch(apiUrl, options = {}) {
const { debounceMs = 300, minLength = 0 } = options
const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
let timer = null
watch(searchQuery, (val) => {
if (timer) clearTimeout(timer)
if (val.length < minLength) {
results.value = []
return
}
timer = setTimeout(async () => {
loading.value = true
const res = await fetch(`${apiUrl}?q=${val}`)
results.value = await res.json()
loading.value = false
}, debounceMs)
})
return { searchQuery, results, loading }
}
<!-- OrdersSearch.vue - 1 Line call -->
<script setup>
import { useSearch } from '@/composables/useSearch'
const { searchQuery, results, loading } = useSearch('/api/orders', { debounceMs: 500, minLength: 3 })
</script>
<!-- ProductsSearch.vue - Likewise 1 line (Different URL) -->
<script setup>
import { useSearch } from '@/composables/useSearch'
const { searchQuery, results, loading } = useSearch('/api/products', { debounceMs: 500, minLength: 3 })
</script>
useSearch関数を1か所で定義 → 5か所で再利用。1か所でデバウンスの設定を変更すれば、すべての場所で反映されます。
(3) 収益
『Composable』のその後:
- コード行数:250 → 60行(-76%)
- 実装に必要な変更点:1件:Composableファイル 1つ
- 新しい検索ボックス:1行入力
- テスト可能:useSearch は単独でユニットテストを行うことができます
3. Composableの基本
(1) 命名規則
// ✅ Correct:useXxx Naming
useMouse()
useLocalStorage('key')
useFetch('/api/users')
useDebounce(searchQuery, 500)
useToggle(false)
// ❌ Error:Other Names
fetchUser() // Not based on use Introduction
mouseTracker() // Not based on use Introduction
getStorage() // Verb get/set Not included hook
(2) 5つの主な特徴
| 機能 | 説明 |
|---|---|
| 「use」で始まる | 業界標準(Reactも「useXxx」を使用している) |
| リアクティブデータの返却 | ref / 計算済み / リアクティブを返す |
| 許容されるパラメータ | 通常、プリミティブ型(文字列/数値/オブジェクト)を受け入れます |
| 単独で使用可能 | コンポーネント内に useXxx() と入力するだけ |
| コンポーザブル | 複数のコンポーザブルをネストできる |
(3) ファイルの整理
src/
├── components/ # Components
├── views/ # Page
├── composables/ # Composable Function(Key Points)
│ ├── useMouse.js
│ ├── useLocalStorage.js
│ ├── useFetch.js
│ ├── useDebounce.js
│ ├── useToggle.js
│ └── index.js # Batch Export
├── stores/ # Pinia stores
└── App.vue
4. 5つの実用的なコンポーザブルな例
(1) useMouse: マウスの位置を追跡する
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
<!-- MouseTracker.vue -->
<script setup>
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>Mouse: {{ x }}, {{ y }}</p>
</template>
(2) useLocalStorage: リアクティブな localStorage
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored !== null ? JSON.parse(stored) : defaultValue)
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return data
}
<!-- ThemeToggle.vue -->
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
const theme = useLocalStorage('theme', 'light')
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
Current: {{ theme }}
</button>
</template>
(3) useFetch:汎用的なデータ取得
// composables/useFetch.js
import { ref } from 'vue'
export function useFetch(url) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchData() {
loading.value = true
error.value = null
try {
const res = await fetch(url.value || url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// Get It Now
fetchData()
return { data, loading, error, refetch: fetchData }
}
<!-- UserList.vue -->
<script setup>
import { useFetch } from '@/composables/useFetch'
const { data: users, loading, error, refetch } = useFetch('/api/users')
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refetch">Refresh</button>
</template>
(4) useDebounce: デバウンス値
// composables/useDebounce.js
import { ref, customRef } from 'vue'
export function useDebounce(value, delay = 300) {
let timer = null
return customRef((track, trigger) => ({
get() {
track()
return value.value
},
set(newValue) {
clearTimeout(timer)
timer = setTimeout(() => {
value.value = newValue
trigger()
}, delay)
}
}))
}
// Usage
const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 500)
watch(debouncedSearch, (val) => {
console.log('Search:', val)
// 500ms Execute later
})
(5) useToggle: トグルスイッチ
// composables/useToggle.js
import { ref } from 'vue'
export function useToggle(initialValue = false) {
const value = ref(initialValue)
function toggle() {
value.value = !value.value
}
function setTrue() {
value.value = true
}
function setFalse() {
value.value = false
}
return { value, toggle, setTrue, setFalse }
}
<!-- ModalToggle.vue -->
<script setup>
import { useToggle } from '@/composables/useToggle'
const { value: showModal, open: setTrue, close: setFalse } = useToggle(false)
</script>
<template>
<button @click="setTrue">Open Modal</button>
<Modal v-if="showModal" @close="setFalse" />
</template>
5. コンポーザブルのベストプラクティス
(1) 6つの主要パラメータの設計
// 1. Basic Parameters
useDebounce(value, 300)
// 2. Configuration Object
useFetch(url, { method: 'POST', body: data })
// 3. Citation Types(Responsive)
useFetch(ref('/api/users'))
// 4. Function Arguments(callback)
useEventListener('click', (e) => console.log(e))
// 5. Multi-parameter
useLocalStorage('key', defaultValue, { mergeDefaults: true })
// 6. Generics(TypeScript)
useLocalStorage<User>('user', { name: '', age: 0 })
(2) 戻り値の設計における5つの重要な考慮事項
// 1. Return directly ref
export function useCounter() {
const count = ref(0)
return { count }
}
// 2. Back ref + Methods
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
// 3. Return to Namespace(Recommendations)
export function useCounter() {
const count = ref(0)
return {
state: { count },
actions: { increment: () => count.value++ }
}
}
// 4. Back ref(Deconstructing Friendship)
export function useCounter() {
return useRef(0) // Return directly ref,For external use .value
}
// 5. Back readonly(Prevent External Modifications)
import { readonly } from 'vue'
export function useCounter() {
const count = ref(0)
return { count: readonly(count) }
}
(3) ライフサイクルの6つの主要なカプセル化
// 1. onMounted + onUnmounted(Most common)
export function useEventListener(event, handler) {
onMounted(() => window.addEventListener(event, handler))
onUnmounted(() => window.removeEventListener(event, handler))
}
// 2. watch Automatic Cleanup
export function useWatch(source, callback) {
const stop = watch(source, callback)
onUnmounted(() => stop())
}
// 3. setInterval Automatic Cleanup
export function useInterval(fn, delay) {
let timer = null
onMounted(() => { timer = setInterval(fn, delay) })
onUnmounted(() => clearInterval(timer))
}
// 4. setTimeout Automatic Cleanup
export function useTimeout(fn, delay) {
let timer = null
onMounted(() => { timer = setTimeout(fn, delay) })
onUnmounted(() => clearTimeout(timer))
}
// 5. Canceling Asynchronous Tasks
export function useAsyncTask(task) {
let cancelled = false
onUnmounted(() => { cancelled = true })
return async () => {
if (cancelled) return
await task()
}
}
// 6. Route Redirect Cleanup
export function useRouteLeave(callback) {
onBeforeRouteLeave((to, from) => {
if (callback()) return false // Prevent Departure
})
}
6. VueUse ライブラリ(30以上のコンポーザブル)
(1) VueUseとは何ですか?
VueUseは、Vueコミュニティが提供するComposableライブラリであり、200以上のすぐに使えるComposable(useMouse、useLocalStorage、useDebounce、useEventListenerなど)を提供しているため、自分で記述する手間を省くことができます。
# Installation
npm install @vueuse/core
// main.js
import { createApp } from 'vue'
import VueUse from '@vueuse/core'
import App from './App.vue'
createApp(App).use(VueUse).mount('#app')
(2) 最もよく使われる5つのVueUseコンポーザブル
import { useMouse, useLocalStorage, useDebounceFn, useEventListener, useToggle } from '@vueuse/core'
// 1. Mouse Position
const { x, y } = useMouse()
// 2. localStorage Responsive
const theme = useLocalStorage('theme', 'light')
// 3. Function Debouncing
const debouncedFn = useDebounceFn(() => {
console.log('Debounced!')
}, 500)
// 4. Global Event Listeners
useEventListener('resize', () => {
console.log('Window resized')
})
// 5. Switch Toggle
const [value, toggle] = useToggle()
(3) 5つの主なユースケース
| シナリオ | VueUse Composable |
|---|---|
| マウスの位置 | useMouse / useMouseInElement |
| スクロール位置 | useScroll / useInfiniteScroll |
| localStorage | useLocalStorage / useStorage |
| ネットワークの状態 | useOnline / useNetwork |
| メディアクエリ | useMediaQuery / useBreakpoints |
| 全画面表示 | useFullscreen |
| クリップボード | useClipboard |
| マウスのドラッグ | useDraggable |
| 要素のサイズ | useElementSize / useResizeObserver |
| デバウンス / スロットル | useDebounceFn / useThrottleFn |
7. 完全な例:5つのコンポーザブルの組み合わせ
(1) ▶ サンプル:. useMouse の完全な実装
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
(2) ▶ サンプル:. useFetch の完全な実装
import { ref, watch, isRef } from 'vue'
export function useFetch(url, options = {}) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchData() {
const requestUrl = isRef(url) ? url.value : url
if (!requestUrl) return
loading.value = true
error.value = null
try {
const res = await fetch(requestUrl, options)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
watch(url, fetchData, { immediate: true })
return { data, loading, error, refetch: fetchData }
}
(3) ▶ サンプル:. useLocalStorage の完全な実装
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored !== null ? JSON.parse(stored) : defaultValue)
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
// Cross-Tab Synchronization
window.addEventListener('storage', (e) => {
if (e.key === key && e.newValue) {
data.value = JSON.parse(e.newValue)
}
})
return data
}
例:4. useDebounce + useThrottle
import { ref, customRef } from 'vue'
// Image Stabilization:After the last operation delay Trigger
export function useDebounce(value, delay = 300) {
let timer = null
return customRef((track, trigger) => ({
get() {
track()
return value.value
},
set(newValue) {
clearTimeout(timer)
timer = setTimeout(() => {
value.value = newValue
trigger()
}, delay)
}
}))
}
// Cost-cutting: Max 1 trigger during delay period
export function useThrottle(value, delay = 300) {
let last = 0
return customRef((track, trigger) => ({
get() {
track()
return value.value
},
set(newValue) {
const now = Date.now()
if (now - last >= delay) {
last = now
value.value = newValue
trigger()
}
}
}))
}
(4) ▶ サンプル:. よくある5つの間違いに関するクイックリファレンス
| エラー | 症状 | 解決策 |
|---|---|---|
| 「use」で始まらない | チームで認識されていない | 「useXxx」という名前 |
| 通常のオブジェクトを返す | リアクティブ性を失う | 参照を返す |
| クリーンアップを行わないことによる副作用 | メモリリーク | onUnmounted でのクリーンアップ |
| 過度な抽象化 | 単純なシナリオではコンポーザブルを使用する | 単純なロジックはコンポーネント内に直接記述する |
| 5段階のネストされたComposable | デバッグが難しい | サブコンポーネントやPiniaに分解する |
(5) ▶ サンプル:. 上位5つのコンポーザブルの性能比較
| パターン | 再利用性 | パフォーマンス | 保守性 | 適用性 |
|---|---|---|---|---|
| コピー&ペースト | ❌ | ⭐⭐⭐ | ❌ | 1回限り |
| ミックスイン (Vue 2) | ⭐⭐ | ⭐⭐ | ⭐⭐ | レガシープロジェクト |
| Composable | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | おすすめ |
| Pinia | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 大規模アプリ |
| イベントバス | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | コンポーネント間の通信 |
❓ よくある質問
setup の最上位レベルで呼び出す必要がありますか?<script setup> の最上位レベルで呼び出す必要があります。useSearch には useFetch + useDebounce を記述することができます。const { data } = useFetch('/api') を行い、data.value でアサーションを実行してください。ユニットテストには Vitest を使用してください。📖 まとめ
- コンポーザブル(フック)とは、ref、computed、watch で構成される再利用可能な関数です。
- 5つの主な特徴:「use」で始まる/リアクティブなレスポンスを返す/パラメータを受け入れる/単独で使用できる/組み合わせ可能
- 5 実践編:useMouse / useLocalStorage / useFetch / useDebounce / useToggle
- 6つの主要なパラメータ設計 + 5つの主要な戻り値設計
- 6つの主要なライフサイクルフック(onMounted/onUnmounted)
- VueUse ライブラリは 200 以上のコンポーザブルを提供しています
- Composable 対 Pinia:小規模なロジックには Composable を、大規模な状態管理には Pinia を使用する
📝 練習問題
(1) 基礎問題(難易度:⭐)
useToggle 関数を実装してください:
- 初期値を受け入れる(デフォルト:false)
- { value, toggle, setTrue, setFalse } を返す
- コンポーネントにおける 4 つの使用シナリオをテストする
(2) 上級問題(難易度:⭐⭐)
useFetchのフルバージョンの実装:
refパラメータ(レスポンシブ URL)に対応していますrefetch方式に対応しています- ウォッチでの再取得に対応しています
- エラー処理と読み込み状況
- 2つのコンポーネントでのテスト
(3) チャレンジ問題(難易度:⭐⭐⭐)
完全な「eコマース用バックエンド・コンポーザブル・ライブラリ」を実装する:
- 5つのコンポーザブル:useCart / useAuth / useSearch / usePagination / useTable
- 各Composableには、少なくとも50行の実装コードと完全なテストが含まれている必要があります
- すべてを
composables/index.jsにエクスポートする - 5つのテストケースを作成する(Vitestを使用)
- 自身の実装とVueUseライブラリの実装を比較してください
- 各コンポーザブルのAPIを文書化する



