Composable — 再利用可能なロジックをカプセル化する
この章では、Vue3 のベストプラクティスを学びます。コンポーザブル関数(Composable)を使って再利用可能なロジックをカプセル化し、コードをよりクリーンで保守しやすくします。
Composable とは?
機能が増えるにつれて、コンポーネント内の JS ロジックはどんどん長くなります。ホームページと詳細ページの両方で記事データを読み込む必要があり、コードが2箇所に重複しています。
Composable(コンポーザブル関数)再利用可能なロジックを独立した JS 関数に抽出し、必要なコンポーネントがどこでも呼び出せるようにするものです。
その本質はリアクティブデータとメソッドを返す普通の関数です。。
なぜ Composable が必要なのか?
修正前と修正後のコード構造を比較してみましょう。
| Composable なし(修正前) | Composable あり(修正後) |
|---|---|
| 各コンポーネントが自分で fetch ロジックを書く | 1つの usePosts() をどこでも再利用する |
| データ読み込みと UI ロジックが混在している | データロジックは独立し、コンポーネントは UI だけに集中する |
| 1つのロジックを変更するのに複数ファイルを変更する必要がある | 1箇所変更するだけで、すべてのコンポーネントが同期更新される |
| コンポーネントコードがビジネスの成長に伴い急速に肥大化する | コンポーネントコードは単一責任で、サイズを制御できる |
Vue2 の Mixins を使ったことがあれば、Composable はより明確で安全な代替品と理解できます。名前の衝突がなく、データの出所が明確です。
命名規約と約束
| 規約 | 説明 | 例 |
|---|---|---|
以 use先頭 | 一目で Composable だとわかる | usePosts、useDarkMode |
置くcomposables/ディレクトリ | 検索や保守が容易 | src/composables/usePosts.js |
| リアクティブデータを返す | ref または reactive を返し、呼び出し側はリアクティブを維持する | return { articles, isLoading } |
| 1つの関数は1つのことだけを行う | 単一責任で、組み合わせやすい | usePosts はデータだけを扱い、useDarkMode はテーマだけを扱う |
usePosts — 記事データのロジックをカプセル化する
この Composable の役割は次のとおりです。データ読み込み + カテゴリ絞り込み + 検索フィルタリング。
例
import { ref, computed, onMounted } from 'vue'
export function usePosts() {
const articles = ref([]) // すべての記事データ
const isLoading = ref(true) // 読み込み状態
const error = ref(null) // エラーメッセージ
const activeCategory = ref('すべて') // 現在選択中のカテゴリ
const keyword = ref('') // 検索キーワード
// すべてのカテゴリを抽出(重複除去)
const categories = computed(() => {
const cats = articles.value.map(a => a.category)
return ['すべて', ...new Set(cats)]
})
// 絞り込み後の記事:まずカテゴリ、次にキーワード
const filteredArticles = computed(() => {
let result = articles.value
// カテゴリで絞り込み
if (activeCategory.value !== 'すべて') {
result = result.filter(a => a.category === activeCategory.value)
}
// キーワードで絞り込み(タイトルまたは要約に一致)
if (keyword.value.trim()) {
const kw = keyword.value.trim().toLowerCase()
result = result.filter(a =>
a.title.toLowerCase().includes(kw) ||
a.summary.toLowerCase().includes(kw)
)
}
return result
})
// ID で単一の記事を検索
function getArticleById(id) {
return articles.value.find(a => a.id === id)
}
// カテゴリを切り替え
function setCategory(cat) {
activeCategory.value = cat
}
// データを読み込み
async function fetchPosts() {
isLoading.value = true
error.value = null
try {
const res = await fetch('/posts.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
articles.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
isLoading.value = false
}
}
// コンポーネントのマウント時に自動読み込み
onMounted(() => {
fetchPosts()
})
// コンポーネントに提供するデータとメソッドを返す
return {
articles,
isLoading,
error,
activeCategory,
keyword,
categories,
filteredArticles,
getArticleById,
setCategory,
fetchPosts
}
}
ホームページコンポーネントはとても簡潔になり、usePosts を呼び出すだけで済みます。
例
<script setup>
import { usePosts } from '../composables/usePosts.js'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'
// 1行で記事に関連するすべてのデータとメソッドを取得
const {
isLoading,
error,
activeCategory,
keyword,
categories,
filteredArticles,
setCategory,
fetchPosts
} = usePosts()
</script>
<template>
<div class="home">
<div class="search-bar">
<input
v-model="keyword"
type="text"
placeholder="記事タイトルまたは要約を検索..."
/>
</div>
<CategoryFilter
:categories="categories"
:active-category="activeCategory"
@update-category="setCategory"
/>
<p v-if="isLoading">読み込み中...</p>
<p v-else-if="error">読み込み失敗:{{ error }}
<button @click="fetchPosts">再試行</button>
</p>
<p v-else-if="filteredArticles.length === 0">一致する記事がありません</p>
<div v-else class="article-grid">
<BlogCard
v-for="article in filteredArticles"
:key="article.id"
:id="article.id"
:title="article.title"
:summary="article.summary"
:date="article.date"
:category="article.category"
/>
</div>
</div>
</template>
詳細ページでも usePosts を再利用でき、fetch を再度書く必要はありません。注意:Vue から別途インポートする必要があります。computed:
例
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { usePosts } from '../composables/usePosts.js'
const route = useRoute()
const id = Number(route.params.id)
const { isLoading, error, getArticleById } = usePosts()
// articles はリアクティブデータで、fetch 完了後 computed が自動的に再計算される
const article = computed(() => getArticleById(id))
</script>
<template>
<div>
<p v-if="isLoading">読み込み中...</p>
<p v-else-if="error">読み込み失敗:{{ error }}</p>
<div v-else-if="!article">記事が存在しません</div>
<article v-else>
<h1>{{ article.title }}</h1>
<div v-html="article.content"></div>
</article>
</div>
</template>
注意:ユーザーが詳細ページの URL に直接アクセスした場合(例:ページの再読み込み)、usePosts はデータを再 fetch します。ホームページと詳細ページは異なるコンポーネントインスタンスであるため、それぞれが usePosts を呼び出すと onMounted 内の fetch が実行され、データは共有されません。ページ間で同じデータを共有する必要がある場合は、状態を Pinia store に持ち上げるか、トップレベルコンポーネントで一度呼び出した後、provide / inject で下位に渡します。
useDarkMode — 1行のコードでダークモードを切り替える
ダークモードの核心的な考え方:htmlタグに class を追加し、CSS 変数で色を制御します。
ユーザーの選択は localStorage に保存され、次回アクセス時に自動的に復元されます。
例
import { ref, watchEffect } from 'vue'
export function useDarkMode() {
// localStorage からユーザーの以前の設定を読み取る(ない場合はデフォルトで light)
const saved = localStorage.getItem('blog-theme')
const isDark = ref(saved === 'dark')
// テーマを DOM に適用
function applyTheme(dark) {
if (dark) {
document.documentElement.classList.add('dark')
localStorage.setItem('blog-theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('blog-theme', 'light')
}
}
// watchEffect は即座に一度実行され、初期テーマが適用される
// その後 isDark が変化するたびに DOM へ自動同期
watchEffect(() => {
applyTheme(isDark.value)
})
// ダークモードを切り替え
function toggleDark() {
isDark.value = !isDark.value
}
return { isDark, toggleDark }
}
NavBar で使用するには、2行だけ必要です。
例
<script setup>
import { useDarkMode } from '../composables/useDarkMode.js'
const { isDark, toggleDark } = useDarkMode()
</script>
<template>
<header class="navbar">
<a href="/" class="logo">サンプル Blog</a>
<nav>
<a href="/">ホーム</a>
<button class="theme-btn" @click="toggleDark">
{{ isDark ? '☀ライト' : '☾ ダーク' }}
</button>
</nav>
</header>
</template>
グローバル CSS 変数と組み合わせてテーマ切り替えを実現する
例
/* ライトモード(デフォルト) */
:root {
--bg-primary: #f5f5f5;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary:#666666;
--border-color: #eeeeee;
}
/* ダークモード(html タグに .dark クラスを追加したときに有効) */
html.dark {
--bg-primary: #1a1a2e;
--bg-card: #16213e;
--text-primary: #e0e0e0;
--text-secondary:#a0a0a0;
--border-color: #2a2a4a;
}
/* コンポーネント内でこれらの変数を参照し、テーマを切り替えてもコンポーネントコードの変更は不要 */
body {
background: var(--bg-primary);
color: var(--text-primary);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
}
このとき切り替えボタンをクリックすると、サイト全体の色が即座に切り替わり、リロード後も選択が記憶されます。
Vue2 Mixin との比較
| 特性 | Mixin(Vue2) | Composable(Vue3) |
|---|---|---|
| データの出所 | 不透明で、プロパティがどこから来るのかわからない | return から明示的に分割代入して取得するため、出所が一目瞭然 |
| 名前の衝突 | 複数の Mixin の同名プロパティが互いに上書きされる | 変数名は自分で決めるため、競合は存在しない |
| 型推論 | TypeScript サポートが弱い | TypeScript をネイティブでサポートし、型が自動推論される |
| ロジックの構成 | オプション別に分類(data/methods/computed) | 機能別に分類し、1つの composable につき1つのファイル |
| ロジックの再利用 | Mixin 同士は組み合わせにくく、暗黙の依存関係が生じやすい | Composable は相互に呼び出しでき、柔軟に組み合わせられる |