- 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ルール
- Container Componentの命名: ファイル名の末尾に
Containerをつける またはindex.vue - Presentational Componentの命名:
Containerを外したファイル名 またはPresenter.vue - propsの流れ: Container → Presentational の一方向のみ
- イベントの流れ: Presentational → Container にemitで通知
- Storeへのアクセス: Container Componentからのみ
- 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設計パターン」についてご紹介させていただきました。
コンポーネント設計に「正解」はありませんが、このパターンを取り入れることで、「どこに何が書いてあるか」が直感的にわかるコードへと進化します。
最初はファイル数が増えることに抵抗を感じるかもしれませんが、一度この快適さを体感すると、大規模な開発では手放せなくなるはずです。まずは、ロジックが肥大化して困っているコンポーネントから、少しずつ切り分けてみることから始めてみてください!
もし、「自分のプロジェクトだとどう構成するのがベストだろう?」と迷ったら、まずは小さな共通パーツからこのパターンを試してみることをおすすめします。
