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 の役割は次のとおりです。データ読み込み + カテゴリ絞り込み + 検索フィルタリング。

例

// ファイルパス: src/composables/usePosts.js
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 を呼び出すだけで済みます。

例

<!-- ファイルパス: src/views/HomeView.vue -->
<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:

例

<!-- ファイルパス: src/views/PostView.vue -->
<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 に保存され、次回アクセス時に自動的に復元されます。

例

// ファイルパス: src/composables/useDarkMode.js
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行だけ必要です。

例

<!-- ファイルパス: src/components/NavBar.vue -->
<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 ? '&#x2600;ライト' : '☾ ダーク' }}
      </button>
    </nav>
  </header>
</template>

グローバル CSS 変数と組み合わせてテーマ切り替えを実現する

例

/* ファイルパス: src/assets/main.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 は相互に呼び出しでき、柔軟に組み合わせられる
その他の拡張