「ブックマークボタンと削除ボタンがあるカード UI、hooks はどこで使うべき?」
チームでこの議論になったこと、ありませんか?カード内で直接useQueryを呼ぶのか、親コンポーネントでデータを取得して props で渡すのか。どちらも動くけど、どっちが「正解」なのか分からない。
実はこれ、2026 年現在でも明確な正解がない問題です。ただし、テストのしやすさという観点で見ると、かなりはっきりした判断基準が見えてきます。
2つの流派:コロケーション vs リフトアップ
React 開発者は大きく 2 つの派閥に分かれています。
コロケーション派(子で直接hooks)
TanStack Query(React Query)のメンテナーである TkDodo は、こう言っています:
「useQuery でデータを取得するコンポーネントがあり、子コンポーネントも同じデータを使う場合、props で渡しても良いし、子で直接 useQuery を呼んでも良い。どうせキャッシュから取得するので。」
つまり、キャッシュがあるから子コンポーネントで直接呼んでも重複リクエストにならないという考え方です。
// コロケーション:カード内で直接hooks
function ArticleCard({ articleId }) {
const { data } = useArticle(articleId);
const { mutate: toggleBookmark } = useBookmarkMutation();
const { mutate: deleteArticle } = useDeleteMutation();
return (
<div>
<h2>{data.title}</h2>
<BookmarkButton onClick={() => toggleBookmark(articleId)} />
<DeleteButton onClick={() => deleteArticle(articleId)} />
</div>
);
}
リフトアップ派(親でhooks、子にprops)
一方、React 公式ドキュメントはこう推奨しています:
「親と子の両方が同じデータを必要とするなら、親コンポーネントでデータを取得し、子に渡すようにしましょう。」
データフローを予測可能にするという考え方です。
// リフトアップ:親でhooks、カードはpropsのみ
function ArticleListPage() {
const { data: articles } = useArticles();
const { bookmarkedIds, toggle } = useBookmarks();
const { remove } = useDeleteArticle();
return (
<ul>
{articles.map(article => (
<ArticleCard
key={article.id}
article={article}
isBookmarked={bookmarkedIds.has(article.id)}
onBookmarkToggle={() => toggle(article.id)}
onDelete={() => remove(article.id)}
/>
))}
</ul>
);
}
// カードはpropsのみ
function ArticleCard({ article, isBookmarked, onBookmarkToggle, onDelete }) {
return (
<div>
<h2>{article.title}</h2>
<BookmarkButton isActive={isBookmarked} onClick={onBookmarkToggle} />
<DeleteButton onClick={onDelete} />
</div>
);
}
テストしやすさで決着をつける
どちらのパターンも動作します。React Query/SWR を使っていれば、mutation したらキャッシュが更新されるので「親の状態を更新する」問題も発生しません。
では何が違うのか?テストの複雑さが全然違います。
コロケーションパターンのテスト
// BookmarkToggle.test.tsx
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
test('ブックマーク状態が表示される', async () => {
// UIのテストなのにMSWモックが必要
server.use(
http.get('/api/bookmarks/:id', () => {
return HttpResponse.json({ isBookmarked: true });
})
);
render(
<QueryClientProvider client={queryClient}>
<BookmarkToggle targetId="123" />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
});
});
問題点:
- UI の見た目を確認したいだけなのに MSW モック必須
- QueryClientProvider のラップが必要
- テストごとに API ハンドラの設定が散らばる
- 「ロジックのテスト」と「見た目のテスト」が混在
リフトアップパターンのテスト
// BookmarkButton.test.tsx(UIのみ)
test('アクティブ状態が表示される', () => {
render(<BookmarkButton isActive={true} onClick={() => {}} />);
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
});
test('非アクティブ状態が表示される', () => {
render(<BookmarkButton isActive={false} onClick={() => {}} />);
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false');
});
// useBookmark.test.ts(ロジックのみ)
test('トグルでAPIが呼ばれる', async () => {
server.use(
http.post('/api/bookmarks/:id/toggle', () => {
return HttpResponse.json({ success: true });
})
);
const { result } = renderHook(() => useBookmark('123'), {
wrapper: QueryClientProvider,
});
await act(() => result.current.toggle());
// APIが呼ばれたことを検証
});
メリット:
- UI テストに MSW 不要、props を渡すだけ
- ロジックテストは 1 箇所でまとめて MSW モック
- 責務が明確に分離
- Storybook もそのまま動く
判断基準:フローチャート
では実際にどう判断すればいいのか。以下のフローチャートを使ってみてください。
そのコンポーネントを複数箇所で再利用する?
│
├─ Yes → propsで受け取る(リフトアップ)
│
└─ No → Storybookで表示したい?
│
├─ Yes → propsで受け取る(APIモックなしで動く)
│
└─ No → そのコンポーネントは本当に分割する意味がある?
│
├─ Yes → propsで受け取る(テストしやすい)
│
└─ No → hooksを直接使ってOK
もう1つの判断基準として、「このコンポーネントを Storybook で表示するとき、API モックが必要か?」と自問してみてください。必要なら、hooks が入りすぎている可能性があります。
実践的なレイヤー分け
Production-Level Patterns for React Hooks という記事では、MVC 風のレイヤー分けを推奨しています:
| レイヤー | 役割 | 例 |
|---|---|---|
| Model | 状態管理・データ取得 | useBookmark.ts |
| View | UIレンダリングのみ | BookmarkButton.tsx |
| Controller | ModelとViewの接続 | BookmarkToggle.tsx |
src/
├── components/ # View(propsのみ)
│ └── BookmarkButton.tsx
├── features/
│ └── bookmark/
│ ├── BookmarkToggle.tsx # Controller(hooksを使う)
│ └── useBookmark.ts # Model(ロジック)
この構造なら:
BookmarkButton→ Storybook で即確認、軽い UI テストuseBookmark→ renderHook + MSW でロジックテストBookmarkToggle→ 結合テストは 1-2 個だけ
コロケーションが適切なケース
ここまでリフトアップを推してきましたが、コロケーションが適切なケースもあります。
- 完全に独立した機能
「いいね」ボタンのように、親の状態に影響せず、どこに置いても独立して動く機能。
// LikeButtonは完全に自己完結
function LikeButton({ targetId, targetType }) {
const { count, toggle } = useLike(targetId, targetType);
return <button onClick={toggle}>{count}</button>;
}
- アプリ固有のページコンポーネント
再利用しない、特定画面専用のコンポーネント。
- プロトタイプ・MVP段階
テストを書く段階ではない、素早く動くものを作りたいとき。
海外での議論
TanStack Query の GitHub Discussion では、この議論が何度も繰り返されています。結論として多いのは:
「どちらのアプローチも正しい。クエリを必要なコンポーネントに配置すれば自己完結性が保たれる。React Query のキャッシュがリクエストを自動で重複排除してくれる。」
つまり、技術的にはどちらも正解。選択基準は:
- コロケーション: コンポーネントの自己完結性を重視
- リフトアップ: テストのしやすさ、型安全性を重視
Kent C. Dodds の「State Colocation」という記事では、「状態は使う場所に近く置け」と主張していますが、これはロジックをコンポーネントに直接書けという意味ではなく、不必要にグローバルに持ち上げるなという意味です。
まとめ
React で hooks をどこで注入すべきかは、一見すると好みの問題に見えます。しかし、テストのしやすさという観点で見ると、判断基準がはっきりします。
| 観点 | コロケーション | リフトアップ |
|---|---|---|
| 実装の手軽さ | 高い | やや冗長 |
| テストの複雑さ | MSWモック必須 | props渡すだけ |
| Storybook | Provider設定必要 | そのまま動く |
| 再利用性 | 中(hooks依存) | 高(propsのみ) |
| 長期的なテスト労力 | 累積的に増加 | 累積的に減少 |
レイヤー分けは過剰ではない。形が変わっただけ。 Container/Presentational パターンは、hooks という形で生き続けています。
「レイヤーごとに責務ごとにテストしておけば、どんどんテストを書く必要がなくなって楽」という直感は正しい。フロントエンドでも、その原則は変わりません。