頑張らないために頑張る

ゆるく頑張ります

React.jsにおける「状態の置き場所」方針

Posted at — Oct 20, 2026

はじめに

Reactの基礎(JSX、useState、useEffect、コンポーネント分割、基本的なAPI通信)を習得したころ、実務で中〜大規模なフロントエンド開発に携わり始めると、次のような問題に直面しました。

実は、これらはすべて「状態の種類に応じた適切な置き場所」を設計できていないことに起因しています。React開発における設計品質は、コンポーネントツリーの分割テクニックやライブラリ選定もさることながら、「状態の責務と所有権(State Ownership)をどこに配置するか」という地味なところでも結構決まる、ということがわかりました。

そこで本記事では、状態の分類法、配置の判断基準、避けるべきアンチパターン、そして実際の業務で使える決定フローチャートを検討してみます。

状態(State)を4つの種類に分類する

「状態の置き場所」で迷う原因は、性質の異なる状態をすべて同一の 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 に詰め込むことが、状態管理の複雑化を招く根本原因です。

原則1:導出可能な値はStateにしない(計算値の徹底)

結構やってしまうアンチパターンが、「他の値から計算できる値を 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>;
}

この実装には深刻な問題があります。

  1. 不要な再レンダリング: firstName の変更によるレンダー → useEffect 実行 → setFullName による追加の再レンダー、と常に2回のレンダリングサイクルが発生します。
  2. 同期ズレ(State Inconsistency): 描画が一瞬古い状態のままになり、画面のチラつきや競合状態(Race Condition)の原因になります。

良い例:レンダー中の直接計算

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 の使用を検討します。通常の文字列結合や単純なフィルタリング程度であれば、レンダーごとの直接計算で十分高速です。

原則2:状態は「可能な限り下」に置く(Colocation)

「状態の持ち上げ(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 を含むページ全体が再レンダリングされます。

良い例:状態のコロケーション(Colocation)

状態は、それを利用する最も末端のコンポーネント(または最小限のサブツリー)に閉じ込めます。

// ツールチップの状態は 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)に持ち上げます。それまではローカルに留めておくのが大原則です。

原則3:Prop Drilling対策は「Context」の前に「コンポーネントの合成」を検討する

Propsのバケツリレーが発生した際、 createContext を使おうとするケースを見ます。しかし、React公式ドキュメントでは Contextを適用する前に「コンポーネントの合成(Component Composition)」を試すこと が推奨されています。

悪い例:Propsを中継するためだけのContext利用

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を解消できます。

原則4:Contextを正しく使うための2大ルール

コンポーネント合成では解決できず、アプリ内の広範囲でデータを共有する必要がある場合(テーマ、認証ユーザー、多言語化など)に初めて Context を利用します。

ただし、実務でContextを使う際は以下の2点を徹底しなければなりません。

ルール(1):カスタムフックで Provider 外の利用を防止する

useContext(MyContext) を直接呼ぶのではなく、安全なカスタムフック経由で公開します。

ルール(2):State(値)と Dispatch(更新関数)を分離する

Reactの Context は、Provider に渡された value の参照が変わると、そのContextを useContext しているすべてのコンポーネントが再レンダリングされます。更新関数と状態値を同じContextにまとめると、更新関数しか使っていないコンポーネントまで巻き添えを食らって再描画されてしまいます。

実務レベルの 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>
  );
}

原則5:URL State と Server State を外部ライブラリに任せる

実際問題、アプリケーションにおける「状態」の大部分は、純粋なクライアント状態ではなく URL か サーバーキャッシュ だったりします。これらを useState や Zustand で自作するのは車輪の再発明であり、バグの温床になります。

A. 検索条件・フィルター・タブは「URL」に置く

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>
  );
}

B. サーバーデータは「TanStack Query(React Query)」や「SWR」に任せる

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>;
}

原則6:Zustand / Redux 等の外部ストアはいつ導入すべきか?

グローバルステート管理ライブラリ(Zustand、Redux Toolkit、Jotai等)は非常に強力ですが、導入すべきケースは限られています。

導入すべきケース

  1. 高頻度で更新され、かつツリーの深い複数箇所で参照されるクライアント状態
    • リッチテキストエディタの選択範囲やアンドゥ/リドゥ履歴
    • リアルタイム共同編集のカーソル位置
    • 複雑なドラッグ&ドロップUIの状態
  2. Contextによる再レンダリング最適化の限界
    • ContextだとProvider配下全体が巻き込まれるが、セレクタ(useStore(state => state.item))を使って必要なコンポーネントだけをピンポイントで再描画したい場合。

逆に言えば、これくらい頻度や難易度が高いケースでなければ、外部ストアを使う理由はさほど多くない、ということでもあります。

Zustand による適切な軽量グローバルストア例

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 / Jotai

まとめ

React開発において「状態をどこに置くべきか」という問いは、「そのデータはどこに属するべき真実(Single Source of Truth)なのか」という設計思想そのものです。堅牢なReactアプリケーションを構築するために、覚えておくべき要点は次の通りです。

  1. まず分類する: クライアントUI、URL、サーバーキャッシュを混同しない。
  2. 二重管理を捨てる: 導出可能な値を無理にStateにせず、useEffect による状態同期をやめる。
  3. コロケーション: 状態は使う場所の最も近くに置き、リフトアップは必要最小限にとどめる。
  4. 合成を優先する: Prop Drillingを見たらContextを作る前に children を検討する。
  5. 適材適所のツール選定: URLはRouter、APIはTanStack Query、複雑なUIはZustand/Contextに割り当てる。

この判断基準をチームや個人開発で徹底することで、保守性が高く、無駄な再レンダリングのない洗練されたReactアプリケーションを実現できます。できると思います。できたらいいな。

参考文献

comments powered by Disqus