404 Not Found

404 Not Found


nginx

コンポーザブル:合成関数

Charlieのチームには重複ロジックが多くあります。価格フォーマットは10のコンポーネントで別々に書かれ, 検索ロジックは3つのページに散在しています。Aliceのお気に入り機能はページ間で状態を共有する必要があります。Bobは汎用的なページネーションロジックが必要です。コンポーザブルがその解決策です。ロジックを再利用可能な関数に抽出します。

1. 学ぶ内容


2. アーキテクトの実話

(1) ペインポイント:コード全体に散在する重複ロジック

MegaShopには価格を「USD 2,999.99」形式にフォーマットする必要があるコンポーネントが10あり, それぞれが独自のIntl.NumberFormat実装を持っています。Aliceはホームページと商品詳細ページの両方で「お気に入り」機能を実装しましたが, ロジックが一貫していません。ホームページでお気に入りに追加した商品が商品詳細ページに表示されません。

(2) コンポーザブルによる解決策

コンポーザブルに抽出すれば, 一度定義すればどこでも再利用できます:

TYPESCRIPT
// composables/usePriceFormat.ts
export function usePriceFormat(price: Ref<number>, currency = 'USD') {
  return computed(() => new Intl.NumberFormat('en-US', {
    style: 'currency', currency
  }).format(price.value))
}

(3) 効果:DRY+一貫性

10のコンポーネントが同じ価格フォーマットロジックを共有し, useWishlistのおかげでお気に入り状態がすべてのページで一貫します。コード量は60%削減され, バグは80%削減されました。


3. 内蔵コンポーザブルの概要

(1) 内蔵コンポーザブル依存関係図

100%
graph TB
    A[Nuxt内蔵コンポーザブル] --> B[データ取得]
    A --> C[状態管理]
    A --> D[ナビゲーション]
    A --> E[Head管理]
    A --> F[コンテキスト]
    A --> G[ユーティリティ]

    B --> B1[useFetch]
    B --> B2[useAsyncData]
    B --> B3[$fetch]
    C --> C1[useState]
    C --> C2[useCookie]
    D --> D1[useRouter]
    D --> D2[useRoute]
    D --> D3[navigateTo]
    E --> E1[useHead]
    E --> E2[useSeoMeta]
    F --> F1[useRequestHeaders]
    F --> F2[useRuntimeConfig]
    G --> G1[useAppConfig]
    G --> G2[useHydration]

(2) コア内蔵コンポーザブルクイックリファレンス

コンポーザブル 目的 SSR対応 戻り値
useFetch データ取得 { data, pending, error, refresh }
useAsyncData 汎用非同期 { data, pending, error, refresh }
useState 共有状態 Ref
useCookie Cookie操作 Ref
useRouter ルーターインスタンス ⚠️ クライアントのみ Router
useRoute 現在のルート Route
useHead Head管理 void
useSeoMeta SEOメタデータ void
useRuntimeConfig ランタイム設定 RuntimeConfig
useRequestHeaders リクエストヘッダー ✅ サーバーのみ Headers

(1) ▶ サンプル:useStateでコンポーネント間の状態を共有

VUE
<script setup lang="ts">
// コンポーネント間で通知状態を共有
const notifications = useState<Notification[]>('notifications', () => [])

function addNotification(message: string, type: 'success' | 'error' = 'success') {
  notifications.value.push({ id: Date.now(), message, type })
  setTimeout(() => {
    notifications.value = notifications.value.filter(n => n.id !== Date.now())
  }, 3000)
}
</script>

出力:

TEXT
// 実行成功

4. カスタムコンポーザブル

(1) 命名とディレクトリ規約

ルール 説明
ディレクトリ composables/ composables/useCart.ts
名前 useプレフィックス usePriceFormat
エクスポート 名前付きエクスポート export function usePriceFormat()
同一ファイルにインターフェース定義 interface PriceOptions {}
自動インポート ✅ 自動 手動インポート不要

(1) ▶ サンプル:usePriceFormat価格フォーマット

TYPESCRIPT
// composables/usePriceFormat.ts
interface PriceFormatOptions {
  currency?: string
  locale?: string
  showDecimals?: boolean
}

export function usePriceFormat(
  price: Ref<number> | number,
  options: PriceFormatOptions = {}
) {
  const { currency = 'USD', locale = 'en-US', showDecimals = true } = options

  const formatted = computed(() => {
    const value = unref(price)
    return new Intl.NumberFormat(locale, {
      style: 'currency',
      currency,
      minimumFractionDigits: showDecimals ? 2 : 0,
      maximumFractionDigits: showDecimals ? 2 : 0
    }).format(value)
  })

  return { formatted }
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:useWishlistお気に入り

TYPESCRIPT
// composables/useWishlist.ts
export function useWishlist() {
  const wishlist = useState<number[]>('wishlist', () => [])

  function toggle(productId: number) {
    const index = wishlist.value.indexOf(productId)
    if (index === -1) {
      wishlist.value.push(productId)
    } else {
      wishlist.value.splice(index, 1)
    }
  }

  function isWishlisted(productId: number): boolean {
    return wishlist.value.includes(productId)
  }

  const count = computed(() => wishlist.value.length)

  return { wishlist, toggle, isWishlisted, count }
}

出力:

TEXT
// 実行成功

(3) ▶ サンプル:useProductSearch検索ロジック

TYPESCRIPT
// composables/useProductSearch.ts
interface SearchParams {
  query?: string
  category?: string
  minPrice?: number
  maxPrice?: number
  sortBy?: 'price' | 'name' | 'rating'
  page?: number
}

export function useProductSearch(initialParams: SearchParams = {}) {
  const params = reactive<SearchParams>({
    query: '',
    category: '',
    minPrice: 0,
    maxPrice: 10000,
    sortBy: 'rating',
    page: 1,
    ...initialParams
  })

  const { data, pending, error, refresh } = useFetch('/api/products/search', {
    query: params,
    watch: [params],
    default: () => ({ items: [], total: 0 })
  })

  function resetFilters() {
    params.query = ''
    params.category = ''
    params.minPrice = 0
    params.maxPrice = 10000
    params.sortBy = 'rating'
    params.page = 1
  }

  return { params, data, pending, error, refresh, resetFilters }
}

出力:

TEXT
// 実行成功

5. コンポーザブル設計パターン

(1) デザインパターンの比較

パターン 特徴 ユースケース
同期コンポーザブル computed/refを返す フォーマット/計算 usePriceFormat
非同期コンポーザブル 内部でuseFetchを呼び出す データ取得 useProductSearch
パラメータリアクティブ watchで自動リフレッシュ フィルター/検索 useProductSearch
状態共有 内部でuseState クロスコンポーネント状態 useWishlist
コンポジット 他のコンポーザブルを呼び出し 複雑ロジック useCheckout

(1) ▶ サンプル:コンポジットコンポーザブル—useCheckout

TYPESCRIPT
// composables/useCheckout.ts
export function useCheckout() {
  // 他のコンポーザブルを合成
  const cartStore = useCartStore()
  const { formatted: total } = usePriceFormat(
    computed(() => cartStore.totalPrice)
  )
  const { isAuthenticated, user } = storeToRefs(useUserStore())

  const isProcessing = ref(false)
  const orderId = ref<string | null>(null)

  async function processCheckout() {
    if (!isAuthenticated.value) {
      navigateTo('/login')
      return
    }

    isProcessing.value = true
    try {
      const order = await $fetch('/api/orders', {
        method: 'POST',
        body: {
          items: cartStore.items,
          userId: user.value?.id,
          total: cartStore.totalPrice
        }
      })
      orderId.value = order.id
      cartStore.clearCart()
      navigateTo(`/orders/${order.id}`)
    } catch (err) {
      console.error('チェックアウト失敗:', err)
    } finally {
      isProcessing.value = false
    }
  }

  return { total, isProcessing, orderId, processCheckout, isAuthenticated }
}

出力:

TEXT
// 実行成功

(2) コンポーザブル設計原則

原則 説明 反例
単一責任 1つのコンポーザブルは1つのことをする useShopAndCart()
パラメータはリアクティブ Refパラメータを受け取る 生の値のみを受け取る
戻り値の分割代入 名前付きオブジェクトを返す 配列を返す
明確な命名 use + 動詞/名詞 doStuff()
副作用なし DOMを直接変更しない 内部でDOMを操作する

6. 総合例:MegaShopコンポーザブルアーキテクチャ

TYPESCRIPT
// composables/usePagination.ts
export function usePagination(totalItems: Ref<number>, pageSize = 20) {
  const route = useRoute()
  const router = useRouter()

  const currentPage = computed(() => Number(route.query.page) || 1)
  const totalPages = computed(() => Math.ceil(totalItems.value / pageSize))
  const hasNext = computed(() => currentPage.value < totalPages.value)
  const hasPrev = computed(() => currentPage.value > 1)

  function goToPage(page: number) {
    router.push({ query: { ...route.query, page: String(page) } })
  }

  function nextPage() {
    if (hasNext.value) goToPage(currentPage.value + 1)
  }

  function prevPage() {
    if (hasPrev.value) goToPage(currentPage.value - 1)
  }

  const offset = computed(() => (currentPage.value - 1) * pageSize)

  return { currentPage, totalPages, hasNext, hasPrev, offset, goToPage, nextPage, prevPage }
}
TYPESCRIPT
// composables/useNotification.ts
interface Notification { id: number; message: string; type: 'success' | 'error' | 'info' }

export function useNotification() {
  const notifications = useState<Notification[]>('notifications', () => [])

  function notify(message: string, type: Notification['type'] = 'success') {
    const id = Date.now()
    notifications.value = [...notifications.value, { id, message, type }]
    setTimeout(() => {
      notifications.value = notifications.value.filter(n => n.id !== id)
    }, 3000)
  }

  function dismiss(id: number) {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }

  return { notifications, notify, dismiss }
}

❓ よくある質問

Q コンポーザブルとPinia Storeのどちらを選ぶべきですか?
A コンポーネント間で永続的な状態を共有する場合 (ショッピングカート, ユーザーデータなど)はStoreを使い, ロジックの再利用 (フォーマット, 検索など)にはコンポーザブルを使います。コンポーザブルはより軽量で, StoreはDevToolsサポートを提供します。
Q コンポーザブル内でuseFetchを使用できますか?
A はい。これは非同期コンポーザブルと呼ばれます。呼び出し時にawaitを使用する必要があります。ページ内と同様です。useProductSearchはこのように設計されています。
Q コンポーザブルはなぜ「use」で始める必要がありますか?
A Nuxt 3の自動インポート機能はcomposables/ディレクトリのファイルをスキャンし, 「use」プレフィックスは必須ではありません。ただし, Vueコミュニティの慣習として「use」で始めることで識別しやすく, ツールサポートも受けやすくなります。
Q コンポーザブル内でuseStateを使用できますか?
A はい。useStateで作成された状態はSSRとクライアント間で自動的に水和されるため, コンポーネント間の共有に適しています。useWishlistuseStateで保存しています。
Q コンポーザブルのパラメータはRefか生の値かどちらにすべきですか?
A Refを受け取ることを推奨します。パラメータの変更時にコンポーザブルがリアクティブに更新できるようになります。unref()を使えば両方の型をサポートできます。
Q 同じコンポーザブルを呼び出す複数のコンポーネントは状態を共有しますか?
A コンポーザブル内でuseStateを使用している場合, 状態は共有されます。refreactiveを使用している場合, コンポーザブルが呼び出されるたびに個別の状態が作成されます。必要に応じて選択してください。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐):usePriceFormatコンポーザブルを作成し, 異なるコンポーネントで価格をUSD/JPY/CNY形式にフォーマットしてください。
  2. 応用問題 (難易度:⭐⭐):useProductSearchを作成し, キーワード検索, カテゴリフィルター, ページネーションを実装してください。watchパラメータ変更時にページが自動リフレッシュされることを検証してください。
  3. チャレンジ (難易度:⭐⭐⭐):useCheckoutコンポジットコンポーザブルを作成し, ショッピングカートStore, 価格フォーマット, 注文送信を統合して完全なチェックアウトプロセスを実装してください。

---|

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%