Reactの基礎(JSX、useState、useEffect、コンポーネント分割、基本的なAPI通信)を習得したころ、実務で中〜大規模なフロントエンド開発に携わり始めると、次のような問題に直面しました。
useState が20数個並び、何がどこで更新されているのか追えない」useState で持ったため、リロードやブラウザバックで状態がリセットされてしまい使い勝手が悪い」実は、これらはすべて「状態の種類に応じた適切な置き場所」を設計できていないことに起因しています。React開発における設計品質は、コンポーネントツリーの分割テクニックやライブラリ選定もさることながら、「状態の責務と所有権(State Ownership)をどこに配置するか」という地味なところでも結構決まる、ということがわかりました。
そこで本記事では、状態の分類法、配置の判断基準、避けるべきアンチパターン、そして実際の業務で使える決定フローチャートを検討してみます。
「状態の置き場所」で迷う原因は、性質の異なる状態をすべて同一の useState(あるいはグローバルストア)で管理しようとすることにあります。まあ「性質が違うのに、同じモノとして扱うのは無理があるよね」という当たり前体操です。
実務におけるReactの状態は、大きく以下の4つに分類されるかと思います。
| 分類 | 内容 |
|---|---|
| 1. Local UI State | モーダル開閉、アコーディオン展開、入力途中のフォーム値 |
| 2. URL State | 検索クエリ、選択中タブ、ソート順、ページネーション |
| 3. Server Cache State | ユーザー一覧、記事詳細、APIレスポンスの非同期データ |
| 4. Global Client State | 認証セッション、UIテーマ、グローバル通知(Toast) |
これらをそれぞれ別物としてちゃんと分類して、それぞれを適切に扱おうねというのが大雑把な方針となります。
例えば「検索条件」は URL State にあるべきです。これを useState や Context に置くと、URLを共有した際に相手に同じ画面が表示されず、ブラウザバックの挙動も壊れます。また、「APIから取得したデータ」は Server Cache State であり、本来クライアントが真の所有権(Ownership)を持つデータではありません。これらを区別せずに useState や Redux に詰め込むことが、状態管理の複雑化を招く根本原因です。
結構やってしまうアンチパターンが、「他の値から計算できる値を useState や useEffect でわざわざ管理する」ことです。「なんだ、そんなことか」と思うことなかれ、割と見たりします。
useEffect による状態の同期姓名からフルネームを生成するために、StateとEffectを乱用するケースです。
import { useState, useEffect } from "react";
export function UserProfile() {
const [firstName, setFirstName] = useState("太郎");
const [lastName, setLastName] = useState("山田");
const [fullName, setFullName] = useState("");
// アンチパターン: 状態変更を検知して別の状態を更新する
useEffect(() => {
setFullName(`${lastName} ${firstName}`);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}この実装には深刻な問題があります。
firstName の変更によるレンダー → useEffect 実行 → setFullName による追加の再レンダー、と常に2回のレンダリングサイクルが発生します。React公式ドキュメントの「You Might Not Need an Effect」でも明言されている通り、既存の Props や State から計算できるものは、State にしてはいけません。
import { useState } from "react";
export function UserProfile() {
const [firstName, setFirstName] = useState("太郎");
const [lastName, setLastName] = useState("山田");
// レンダー中に直接計算する(Single Source of Truth の維持)
const fullName = `${lastName} ${firstName}`;
return <div>{fullName}</div>;
}計算コストが極めて高い処理(数千件の配列操作など)のみ、useMemo の使用を検討します。通常の文字列結合や単純なフィルタリング程度であれば、レンダーごとの直接計算で十分高速です。
「状態の持ち上げ(Lifting State Up)」を覚えると次に陥る罠が、「将来共有するかもしれないから」とすべての状態を親コンポーネント(App や Page)へ持ち上げてしまう、過剰リフトアップです。何でもかんでも持ち上げられたら、親だって困るという話です。
export function ProductPage() {
// 親が子のローカルなUI状態まで抱え込んでいる
const [isModalOpen, setIsModalOpen] = useState(false);
const [tooltipVisible, setTooltipVisible] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
return (
<div>
<SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
<ProductList onOpenModal={() => setIsModalOpen(true)} />
<HelpTooltip visible={tooltipVisible} onToggle={setTooltipVisible} />
<ProductModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
);
}親コンポーネントが状態を持つと、例えばツールチップを開閉しただけで ProductList を含むページ全体が再レンダリングされます。
状態は、それを利用する最も末端のコンポーネント(または最小限のサブツリー)に閉じ込めます。
// ツールチップの状態は HelpTooltip 自身が所有する
export function HelpTooltip() {
const [isVisible, setIsVisible] = useState(false);
return (
<div>
<button onClick={() => setIsVisible((v) => !v)}>?</button>
{isVisible && <div className="tooltip">ヘルプテキスト</div>}
</div>
);
}状態を共有する必要が生じたその瞬間にだけ、最も近い共通祖先(LCA: Lowest Common Ancestor)に持ち上げます。それまではローカルに留めておくのが大原則です。
Propsのバケツリレーが発生した際、 createContext を使おうとするケースを見ます。しかし、React公式ドキュメントでは Contextを適用する前に「コンポーネントの合成(Component Composition)」を試すこと が推奨されています。
Page (user を持っている)
└ Layout
└ Header
└ UserAvatar (user を使いたい)これを解決するためにグローバルな Context を作成すると、コンポーネントの依存関係が暗黙的になり、テストや再利用が難しくなります。何にしても、グローバルを理由なく安易に使っちゃうのは良くないわけです。
children やコンポーネントスロットを利用した合成Layout や Header に user を中継させるのではなく、末端のコンポーネントを親側で組み立てて children や Props として渡します。
import { ReactNode } from "react";
type User = { id: string; name: string; avatarUrl: string };
// Layout は中身(children)を知る必要がない
function Layout({ header, children }: { header: ReactNode; children: ReactNode }) {
return (
<div className="layout">
<header>{header}</header>
<main>{children}</main>
</div>
);
}
function UserAvatar({ user }: { user: User }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
// 親(Page)で結合する
export function Page({ currentUser }: { currentUser: User }) {
return (
<Layout
header={<UserAvatar user={currentUser} />}
>
<div>メインコンテンツ</div>
</Layout>
);
}このパターン(制御の反転: Inversion of Control)を使うことで、中間コンポーネントを汚染せずにProp Drillingを解消できます。
コンポーネント合成では解決できず、アプリ内の広範囲でデータを共有する必要がある場合(テーマ、認証ユーザー、多言語化など)に初めて Context を利用します。
ただし、実務でContextを使う際は以下の2点を徹底しなければなりません。
useContext(MyContext) を直接呼ぶのではなく、安全なカスタムフック経由で公開します。
Reactの Context は、Provider に渡された value の参照が変わると、そのContextを useContext しているすべてのコンポーネントが再レンダリングされます。更新関数と状態値を同じContextにまとめると、更新関数しか使っていないコンポーネントまで巻き添えを食らって再描画されてしまいます。
import { createContext, useContext, useState, ReactNode, Dispatch, SetStateAction } from "react";
type Theme = "light" | "dark";
// 1. State用 と Dispatch用 のContextを分離
const ThemeStateContext = createContext<Theme | undefined>(undefined);
const ThemeDispatchContext = createContext<Dispatch<SetStateAction<Theme>> | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
return (
<ThemeStateContext.Provider value={theme}>
<ThemeDispatchContext.Provider value={setTheme}>
{children}
</ThemeDispatchContext.Provider>
</ThemeStateContext.Provider>
);
}
// 2. カスタムフックでラップし、Provider漏れを型安全に検知
export function useTheme(): Theme {
const context = useContext(ThemeStateContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
export function useSetTheme(): Dispatch<SetStateAction<Theme>> {
const context = useContext(ThemeDispatchContext);
if (context === undefined) {
throw new Error("useSetTheme must be used within a ThemeProvider");
}
return context;
}ボタンコンポーネントは更新関数しか購読しないため、テーマが切り替わっても不要な再レンダリングが発生しません。
export function ThemeToggleButton() {
// テーマ自体の値(light/dark)は購読せず、セッターだけを取得
const setTheme = useSetTheme();
return (
<button onClick={() => setTheme((prev) => (prev === "light" ? "dark" : "light"))}>
テーマ切り替え
</button>
);
}実際問題、アプリケーションにおける「状態」の大部分は、純粋なクライアント状態ではなく URL か サーバーキャッシュ だったりします。これらを useState や Zustand で自作するのは車輪の再発明であり、バグの温床になります。
URLに状態を持たせることで、以下の恩恵を自動的に享受できます。
// React Router や Next.js の useSearchParams を利用する例
import { useSearchParams } from "react-router-dom";
export function SearchFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const currentCategory = searchParams.get("category") ?? "all";
const handleCategoryChange = (newCategory: string) => {
setSearchParams((prev) => {
prev.set("category", newCategory);
prev.set("page", "1"); // 絞り込み時はページをリセット
return prev;
});
};
return (
<select
value={currentCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
>
<option value="all">すべて</option>
<option value="books">書籍</option>
<option value="electronics">家電</option>
</select>
);
}APIからフェッチしたデータ(Server State)を useState と useEffect で管理しようとすると、キャッシュ破棄、重複リクエストの排除、ローディング/エラー状態の管理コードでコンポーネントが埋め尽くされます。
import { useQuery } from "@tanstack/react-query";
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("ネットワークエラー");
return res.json();
}
export function UserDetail({ userId }: { userId: string }) {
// キャッシュ管理、再フェッチ、ローディング状態はすべてライブラリが担当
const { data: user, isLoading, error } = useQuery({
queryKey: ["users", userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5分間はキャッシュを新鮮とみなす
});
if (isLoading) return <div>読み込み中...</div>;
if (error) return <div>エラーが発生しました</div>;
return <div>{user.name}</div>;
}グローバルステート管理ライブラリ(Zustand、Redux Toolkit、Jotai等)は非常に強力ですが、導入すべきケースは限られています。
useStore(state => state.item))を使って必要なコンポーネントだけをピンポイントで再描画したい場合。逆に言えば、これくらい頻度や難易度が高いケースでなければ、外部ストアを使う理由はさほど多くない、ということでもあります。
import { create } from "zustand";
type Notification = { id: string; message: string };
type NotificationStore = {
notifications: Notification[];
addNotification: (message: string) => void;
removeNotification: (id: string) => void;
};
export const useNotificationStore = create<NotificationStore>((set) => ({
notifications: [],
addNotification: (message) =>
set((state) => ({
notifications: [
...state.notifications,
{ id: crypto.randomUUID(), message },
],
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
}));コンポーネントは必要なプロパティのみをセレクタで指定します。関係ない状態が変化しても再レンダリングされません。
// 通知追加ボタン: notifications 配列の変更では再レンダリングされない
export function TriggerButton() {
const addNotification = useNotificationStore((state) => state.addNotification);
return (
<button onClick={() => addNotification("タスクが完了しました")}>
通知を表示
</button>
);
}新しい状態を定義する際は、以下のフローチャートを上から順に判定してください。
[新しいデータを定義したい]
│
├─ Q1. そのデータはサーバー(API)に原本があるか?
│ └─ YES: ──▶ 【Server Cache】TanStack Query / SWR
│
├─ Q2. ユーザーがリロードしたりURLを共有したときに復元すべきか?
│ (検索ワード、ソート順、選択中タブ、ページ番号など)
│ └─ YES: ──▶ 【URL State】useSearchParams (Router)
│
├─ Q3. 他のPropsやStateから計算可能か?
│ └─ YES: ──▶ 【計算値】変数としてレンダー中に直接計算 (必要なら useMemo)
│
├─ Q4. 1つのコンポーネント(またはその直下)だけで使うか?
│ └─ YES: ──▶ 【Local UI State】useState / useReducer
│
├─ Q5. 親子関係でバケツリレーが深いだけか?
│ └─ YES: ──▶ 【コンポーネント合成】children やスロットPropsで解決
│
├─ Q6. アプリ全体で共有する低頻度更新のデータか?(テーマ、認証など)
│ └─ YES: ──▶ 【React Context】State/Dispatch分離パターンを適用
│
└─ 上記すべてに当てはまらない、高頻度更新・複雑なクライアントデータ
└─ YES: ──▶ 【Global Store】Zustand / JotaiReact開発において「状態をどこに置くべきか」という問いは、「そのデータはどこに属するべき真実(Single Source of Truth)なのか」という設計思想そのものです。堅牢なReactアプリケーションを構築するために、覚えておくべき要点は次の通りです。
useEffect による状態同期をやめる。children を検討する。この判断基準をチームや個人開発で徹底することで、保守性が高く、無駄な再レンダリングのない洗練されたReactアプリケーションを実現できます。できると思います。できたらいいな。