Nuxt.jsでのコンポーネント設計:Container/Presentational パターン入門

この記事で解決できるお悩み
  • Nuxt.jsでコンポーネントの責務が曖昧になり、コードが肥大化している
  • ロジックとUIが混在してテストやメンテナンスがしづらい
  • チーム開発で「見た目を作る人」と「ロジックを作る人」の作業分担をスムーズにしたい
  • Storybookを導入したいが、どうコンポーネントを設計すればいいか分からない

Nuxt.jsでアプリケーションを開発していると、気づけば1つのコンポーネントに多くの責務が詰め込まれ、保守が困難になっていませんか?特にビジネスロジック、API通信、UIの表示がすべて1つのファイルに混在すると、テストもしづらくなります。

この記事では、Nuxt.jsにおけるContainer/Presentational パターンを活用して、ロジックと見た目をきれいに切り分ける方法を解説します。

目次

Container/Presentational Patternとは

Container/Presentational Pattern(コンテナ/プレゼンテーショナル パターン)は、Reactの開発者Dan Abramov氏が提唱したコンポーネント設計パターンです。このパターンは、コンポーネントを「ロジックを担当するContainer Component」と「UIを担当するPresentational Component」の2つに分離することで、関心の分離を実現します。

元々はReact Hooksが登場する前に提唱されたパターンですが、Vue.jsやNuxt.jsでも十分に有効な設計パターンとして広く採用されています。特に中〜大規模なアプリケーション開発では、コンポーネントの役割を明確にするために非常に役立ちます。

Container ComponentとPresentational Componentの責務

Container Componentの責務

Container Componentは、アプリケーションのロジック部分を担当します。具体的な責務は以下の通りです。

  • データの取得と管理: APIとの通信、Storeとのやり取り
  • 状態管理: ref、reactiveを使った状態の保持
  • ビジネスロジック: データの加工、計算処理
  • イベントハンドラの定義: ボタンクリック時などの処理
  • 子コンポーネントへのデータ受け渡し: Presentational Componentにpropsでデータを渡す

Container Componentは基本的にUIマークアップを持たず、Presentational Componentを配置するだけのシンプルな構造になります。

Presentational Componentの責務

Presentational Componentは、UIの表示に専念します。具体的な責務は以下の通りです。

  • UIの表示: マークアップ、スタイリング
  • propsでデータを受け取る: 親コンポーネントからデータを受け取る
  • イベントの発火: emitで親にイベントを通知
  • UIの状態管理のみ: データとは無関係なUI状態(モーダルの開閉など)

Presentational Componentは、データがどこから来るのか、どう変更されるのかを知る必要がありません。純粋にpropsで受け取ったデータを表示し、ユーザーの操作をemitで親に伝えるだけです。

メリット

コンポーネントの責務が明確になる

ロジックとUIが分離されることで、「このコンポーネントは何をするのか」が一目で分かるようになります。Container Componentを見ればロジックが、Presentational Componentを見ればUIが把握できるため、コードの可読性が大幅に向上します。

テストしやすくなる

Presentational Componentは純粋にpropsを受け取って表示するだけなので、単体テストが非常に書きやすくなります。モックデータをpropsで渡すだけで、様々なパターンのUIをテストできます。一方、Container Componentではロジックに集中してテストを書けるため、テストの見通しも良くなります。

実装者の分担ができる

ロジック先行で実装する人と、CSS/マークアップに集中する人で並行作業がしやすくなります。

Storybookとの相性が良い

Presentational Componentはpropsのみに依存するため、Storybookでの表示が容易です。様々なpropsのパターンを定義することで、コンポーネントカタログを簡単に作成でき、デザインシステムの構築にも役立ちます。

デメリット

作成ファイル数が多くなる

1つの機能に対してContainer ComponentとPresentational Componentの2つのファイルが必要になるため、ファイル数が増えます。小規模なプロジェクトや単純なコンポーネントでは、かえって煩雑になる可能性があります。

propsのバケツリレーが起きやすい

深い階層のコンポーネント構造では、propsを何層にもわたって渡す「propsのバケツリレー」が発生しやすくなります。この問題に対しては、provide/injectやPiniaなどの状態管理ライブラリを併用することで解決できます。

どういうケースで採用すべきか

すべてのコンポーネントにContainer/Presentational Patternパターンを適用する必要はありません。以下のケースで導入を検討するのがおすすめです。

採用を推奨するケース

  • プロジェクトの規模が大きい、または長期運用が想定される場合
  • 同じ見た目で異なるロジックを持たせたい場合
  • Storybookを活用してUIコンポーネントを管理したい場合
  • 複雑なビジネスロジックが含まれるページや機能
  • ロジックと見た目で役割分担したい場合

採用を見送るケース

  • 小規模なプロジェクトで、ファイル数の増加がデメリットになる場合
  • ロジックがほとんどなく、UIの表示だけで完結する場合
  • 個人開発など設計の恩恵よりも開発速度を優先したい場合

Nuxtでの実装例

ディレクトリ構成

components/
├── container/
│   └── todo/
│       └── AddTodoButtonContainer.vue  // Container Component
└── presentational/
    └── todo/
        └── AddTodoButton.vue  // Presentational Component

または、機能ごとにまとめる方式も有効です。

components/
└── todo/
    ├── AddTodoButton/
    │   ├── index.vue        // Container Component
    │   └── Presenter.vue    // Presentational Component
    └── TodoList/
        ├── index.vue
        └── Presenter.vue

ルール

  1. Container Componentの命名: ファイル名の末尾にContainerをつける または index.vue
  2. Presentational Componentの命名: Containerを外したファイル名 または Presenter.vue
  3. propsの流れ: Container → Presentational の一方向のみ
  4. イベントの流れ: Presentational → Container にemitで通知
  5. Storeへのアクセス: Container Componentからのみ
  6. API通信: Container Componentまたはcomposables層で実施

Container Component

API通信や状態管理を担当し、Presenterを呼び出します。

<script setup lang="ts">
import FollowButton from '../presentational/FollowButton.vue'

/**
 * @prop {string} userId - フォロー対象となるユーザーの固有ID
 */
const props = defineProps<{ userId: string }>()

/** @type {Ref<boolean>} フォロー中かどうかのフラグ */
const isFollowing = ref(false)

/** @type {Ref<boolean>} 通信中(ローディング)かどうかのフラグ */
const isLoading = ref(false)

/**
 * フォロー状態を切り替える非同期関数
 * @async
 * @description
 * API通信を模倣し、完了後にフォロー状態を反転させます。
 * エラーハンドリング(try-finally)により、成否に関わらずローディングを解除します。
 */
const toggleFollow = async () => {
  isLoading.value = true
  try {
    // 擬似的なAPIコール(1秒待機)
    await new Promise(resolve => setTimeout(resolve, 1000))
    isFollowing.value = !isFollowing.value
  } finally {
    isLoading.value = false
  }
}
</script>

<template>
  <FollowButton
    :is-following="isFollowing"
    :is-loading="isLoading"
    @click="toggleFollow"
  />
</template>

Presentational Component

見た目とイベントの放出のみを担当します。

<script setup lang="ts">
/**
 * @prop {boolean} isFollowing - 現在フォロー中かどうか
 * @prop {boolean} isLoading - 通信処理が実行中かどうか
 */
interface Props {
  isFollowing: boolean
  isLoading: boolean
}
defineProps<Props>()

/**
 * @event click - ボタンがクリックされた際に発行されるイベント
 */
const emit = defineEmits<{
  (e: 'click'): void
}>()
</script>

<template>
  <button 
    :class="['btn', isFollowing ? 'is-active' : '']"
    :disabled="isLoading"
    @click="emit('click')"
  >
    {{ isLoading ? '処理中...' : (isFollowing ? 'フォロー中' : 'フォローする') }}
  </button>
</template>

<style scoped>
.btn { /* スタイルを記述 */ }
.is-active { /* アクティブ時のスタイル */ }
</style>

まとめ

最後まで読んでいただき、ありがとうございました!

今回は、「NuxtにおけるContainer/Presentational設計パターン」についてご紹介させていただきました。

コンポーネント設計に「正解」はありませんが、このパターンを取り入れることで、「どこに何が書いてあるか」が直感的にわかるコードへと進化します。

最初はファイル数が増えることに抵抗を感じるかもしれませんが、一度この快適さを体感すると、大規模な開発では手放せなくなるはずです。まずは、ロジックが肥大化して困っているコンポーネントから、少しずつ切り分けてみることから始めてみてください!

もし、「自分のプロジェクトだとどう構成するのがベストだろう?」と迷ったら、まずは小さな共通パーツからこのパターンを試してみることをおすすめします。

この記事が気に入ったら
フォローしてね!

シェアしていただけると大変励みになります!
  • URLをコピーしました!
  • URLをコピーしました!
目次