「Hooksがあるから、Presenter/Containerパターンはもう古い」
そう思っていませんか?Dan Abramov自身が「もう推奨しない」と言っているし、カスタムhooksに抽出すれば同じ分離ができる。理屈としては正しいです。
でも、Storybookを使っていると話が変わります。テスト戦略を考えると話が変わります。デザイナーとの分業があると話が変わります。
この記事では、Hooks時代でもPresenter分離が有効なケースと、その判断基準を解説します。Props Soup vs Compound Componentの続編として、コンポーネント設計の別の軸を掘り下げます。
Dan Abramovの見解とその限界
まず、Dan Abramovが元記事に追記した内容を確認しましょう。
「このパターンはもう推奨しない。コードベースで自然なら使っても良いが、必要もないのに教義のように強制されるのを見すぎた」
理由は明確で、Hooksで同じ分離ができるからです。
// ❌ 昔: Container + Presentational
function OrderSummaryContainer() {
const { data } = useOrder()
return <OrderSummaryPresenter order={data} />
}
// ✅ 今: Custom Hook + 1つのコンポーネント
function OrderSummary() {
const { data } = useOrder() // ロジックはhookに抽出
return <div>...</div> // 描画はここ
}
これ、理屈としては完全に正しいんですよね。でも「現場で使ってみると…」という話が抜けてる気がするんです。
見落とされている3つの観点
Dan Abramovの見解が見落としているのは、以下の3点です。
- Storybookとhooksの相性問題
- 分業ワークフロー(デザイン先行開発)
- テスト分類の明確さ
順番に見ていきましょう。
Storybookとhooksの相性問題
Storybook公式の思想を確認すると、こう書かれています。
「Storybookはデータ、API、ビジネスロジックなしでコンポーネントを実装できる」
これ、まさに純粋なPresenterの定義そのものです。
問題: hooks入りコンポーネントはStoryが書きにくい
GitHub Issue #11227やMediumの記事で議論されている通り、hooks入りコンポーネントには課題があります。
「useQueryやuseMutationなどのhooksを使うコンポーネントは、Storybookで動かせないことが多い」
「RESTエンドポイントを呼ぶhookがあると、テストやStorybook環境でレンダリングに失敗する」
実際に書くとこうなります。
// hooks入りコンポーネント
function UserProfile() {
const { data, isLoading } = useUser() // APIを呼ぶ
const { mutate } = useUpdateUser() // 更新もする
if (isLoading) return <Skeleton />
return <div>{data.name}</div>
}
// ↓ Storyを書くには...
export const Default: Story = {
decorators: [
(Story) => (
<QueryClientProvider client={mockQueryClient}>
<Story />
</QueryClientProvider>
),
],
parameters: {
msw: {
handlers: [
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ name: 'Test User' }))
}),
],
},
},
}
これ、1つのStoryを書くだけでこの量。10個のコンポーネントがあったら…考えたくないですね。
解決策: Presenterを分離する
// Presenter(純粋なUI)
function UserProfilePresenter({ user, onUpdate }: Props) {
return (
<div>
<h1>{user.name}</h1>
<button onClick={onUpdate}>更新</button>
</div>
)
}
// Container(hooks呼び出し)
function UserProfile() {
const { data } = useUser()
const { mutate } = useUpdateUser()
return <UserProfilePresenter user={data} onUpdate={mutate} />
}
Storyはシンプルになります。
// Storyはmockなしで書ける
export const Default: Story = {
args: {
user: { name: 'Test User' },
onUpdate: fn(),
},
}
export const LongName: Story = {
args: {
user: { name: 'Very Long Name That Might Break Layout' },
onUpdate: fn(),
},
}
Woltの実例
Wolt Engineering Blogでは、認証フローのリフレッシュ時にこのパターンを採用しています。
「認証hooksを注入可能(injectable)にした結果、Storybookのストーリー作成がstraightforwardになった。Jestのテストもtype-unsafeなモジュールmockingが不要になった」
分業ワークフロー(デザイン先行開発)
FreeCodeCampやTSH Blogの記事では、分業のメリットが明確に述べられています。
「2人が同時に1つのものに取り組める。ジュニアがスタイルとJSXを書き、経験豊富な開発者がロジックを書ける」
「Presentationalコンポーネントはアプリのロジックを変更しないため、コードベースの知識がない人(デザイナーなど)でも見た目を変更できる」
デザイン→実装の流れ
実際のプロジェクトでは、こんな流れが多いのではないでしょうか。
- デザイナー: Figmaでデザイン完成
- フロントエンド(UI担当): デザイン通りにコンポーネント実装
- フロントエンド(ロジック担当): API連携、状態管理を実装
- 結合: ContainerでPresenterとhooksを繋ぐ
この流れで、ステップ2と3が並行して進められるのがPresenter分離の強みです。
hooksの手戻りが起きたとき
APIの仕様変更、状態管理の設計変更…手戻りは必ず起きます。
hooks入りコンポーネントの場合:
- hooksを修正 → コンポーネント再レンダリングに影響
- Storyが壊れる → 修正が必要
- snapshotテストが壊れる → 更新が必要
Presenterを分離している場合:
- hooksを修正 → Containerだけ修正
- Presenterはpropsが同じなら何も変わらない
- Storyもsnapshotもそのまま動く
この「影響範囲の局所化」が、大規模プロジェクトでは本当に効いてくるんですよね。
テスト戦略の明確化
Testing LibraryのrenderHookを使えば、hooksを単独でテストできます。
// hooks/useOrder.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { useOrder } from './useOrder'
test('注文データを取得できる', async () => {
const { result } = renderHook(() => useOrder('order-123'))
await waitFor(() => {
expect(result.current.data).toBeDefined()
})
})
テストの分類が明確になる
| テスト対象 | テスト種別 | ツール |
|---|---|---|
| Presenter | UIテスト、snapshot | Storybook, Jest |
| Custom Hook | ロジックテスト | renderHook |
| Container | 統合テスト | React Testing Library |
DEV Communityの記事では、こう述べられています。
「Pure Componentのテストはシンプル。エラーがあればpropsを確認するだけ。このコンポーネント内でstateが更新されているか心配する必要がない」
hooks入りコンポーネントのテストは複雑
// hooks入りコンポーネントのテスト
test('ユーザー情報を表示する', async () => {
// 1. API mockを設定
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ name: 'Test User' }))
})
)
// 2. Providerでラップ
render(
<QueryClientProvider client={queryClient}>
<UserProfile />
</QueryClientProvider>
)
// 3. 非同期処理を待つ
await waitFor(() => {
expect(screen.getByText('Test User')).toBeInTheDocument()
})
})
Presenterなら、こうなります。
// Presenterのテスト
test('ユーザー名を表示する', () => {
render(<UserProfilePresenter user={{ name: 'Test User' }} />)
expect(screen.getByText('Test User')).toBeInTheDocument()
})
「テストのために分離する」は正当なアプローチ
「テストのために設計を変えるのは本末転倒では?」と思うかもしれません。でも、Kent Beckはこう言っています。
「TDDはデザインに進化的な圧力をかける」 「テストを先に考えることで、インターフェースを先に考えることを強制する。これが良い設計の鍵だ」
Microsoft Learnの記事や学術研究でも、テスタビリティと良い設計の相関が示されています。
「テスタビリティは、カプセル化、結合、凝集性と高い相関がある」 「弱い凝集性、強い結合、冗長性、カプセル化の欠如 = テストしにくい」
つまり、テストしにくいコードは設計に問題があるシグナル。テスタビリティを設計の指針にすることは、結果的に良い設計につながります。学術研究では、Design for Testability(テスタビリティのための設計)が開発予算の約10%を節約するという結果も出ています。
判断基準: いつPresenterを分離すべきか
「中規模と大規模の区別が難しい」という問題があります。プロジェクトの規模感で判断するより、具体的な条件で判断しましょう。
Presenter分離が有効なケース
-
Storybookを積極的に使っている
- コンポーネントカタログとして運用
- デザイナーがStorybookを確認する
-
分業がある
- UI実装とロジック実装が別の人/タイミング
- デザイン先行で開発している
-
テストを重視している
- UIの見た目テスト(snapshot)を書いている
- hooksのロジックテストを書いている
-
コンポーネントが再利用される
- 同じUIで異なるデータソースを使うケース
- デザインシステムのコンポーネント
分離しなくてよいケース
- Storybookを使っていない
- 一人で開発している
- コンポーネントが使い捨て(1画面でしか使わない)
- hooksが単純(useStateが1つだけ、など)
まとめ
「Hooksがあるからcontainer不要」という風潮は、Storybook・テスト・分業の観点が抜けていると言えます。
| 観点 | hooks入りコンポーネント | Presenter分離 |
|---|---|---|
| Storybook | mockが複雑 | mockなしで書ける |
| 分業 | 並行作業が難しい | UI/ロジックを分担可能 |
| テスト | 統合テストになりがち | 単体テストが明確 |
| 手戻り | 影響範囲が広い | Presenterは影響なし |
判断基準をもう一度:
- Storybookを使っている → 分離を検討
- 分業がある → 分離を検討
- テストを重視している → 分離を検討
- 上記すべてNO → hooks入りコンポーネントで十分
Dan Abramovの「必要もないのに教義のように強制するな」という警告は正しいです。ただし、必要なケースでは依然として有効なパターンです。自分のプロジェクトの状況に合わせて判断してください。