1. 現場の病巣:「とりあえずグローバルStore」がもたらす同期地獄

ReactやVueを中心としたWebフロントエンド開発において、「プロジェクトの規模が大きくなってきたから、そろそろグローバル状態管理ライブラリ(Redux Toolkit、Zustand、Jotai、Recoil、Pinia等)を入れよう」という議論は、あたかも開発の通過儀礼であるかのように交わされます。そして、次のような構成のコードベースが爆誕します。

// 現場で無数に量産される「Storeにリモートデータを突っ込む」アンチパターン
import { create } from 'zustand';

interface UserStore {
  user: UserProfile | null;
  posts: Post[];
  isLoading: boolean;
  fetchProfile: () => Promise<void>;
  fetchPosts: () => Promise<void>;
  updateProfile: (data: UpdateProfileInput) => Promise<void>;
}

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  posts: [],
  isLoading: false,
  fetchProfile: async () => {
    set({ isLoading: true });
    const res = await api.getProfile();
    set({ user: res.data, isLoading: false });
  },
  fetchPosts: async () => {
    set({ isLoading: true });
    const res = await api.getPosts();
    set({ posts: res.data, isLoading: false });
  },
  updateProfile: async (data) => {
    await api.patchProfile(data);
    // 更新後にStoreのstateを手動で書き換えるか、再度fetchProfileを呼ぶ...
    set((state) => ({ user: state.user ? { ...state.user, ...data } : null }));
  },
}));

一見すると「データとアクションがStoreにカプセル化されていて綺麗」に見えるかもしれません。コンポーネント側も const { user, fetchProfile } = useUserStore() と呼ぶだけで、何層ものバケツリレーなしにデータを取り出せます。

しかし、このコードが本番運用に入った瞬間、チームは「終わりのない手動データ同期の泥沼」へと叩き落とされます。

現場を襲う4大バグの嵐

「なぜこれほど慎重にStoreを設計しているのに、データ同期のバグが後を絶たないのか?」
答えは残酷なほどシンプルです。あなたがStoreに突っ込んでいるそのデータは、そもそもフロントエンドが管理すべき『状態(State)』ではないからです。

2. 状態の正体を解剖する:あなたが管理しているデータは本当に「State」か?

問題の本質は、「状態(State)」という言葉の解像度が低すぎることです。Webフロントエンドで扱われるデータは、その所有権(Ownership)とライフサイクルによって、全く異なる3つの種類に分類されなければなりません。

データの3層完全分類マトリクス

データ種別 具体例 真の所有者 (Owner) 特性と要件 全体に占める割合
Server State ユーザー一覧、注文履歴、残高、記事詳細 バックエンド DB 非同期、一時的なキャッシュ、他者による変更あり、失効判定が必要 約 80%
URL State 検索キーワード、ソート順、ページ番号、タブ選択、モーダルID ブラウザのアドレスバー (URL) 共有可能、リロード耐性、戻る/進む(History)連動が必要 約 15%
Client State アコーディオンの開閉、ドラッグ中の座標、未送信フォームの入力値 UI コンポーネント 同期的、完全ローカル、ページ離脱で破棄されて構わない 約 5%

Server Stateは「状態」ではなく「リモートキャッシュ」である

Web APIを叩いて取得した商品データやユーザー情報。これらはクライアント側が「所有」しているデータではありません。バックエンドのRDBMSに存在する真実(Single Source of Truth)の、「ある一瞬を切り取ったスナップショット(ただのキャッシュ)」に過ぎません。

サーバー側のデータは、あなたが画面を見ている間にも、他のユーザーの操作やバッチ処理によってリアルタイムに書き換わります。そんなデータをクライアントの同期的メモリ変数(ReduxやZustandのStore)に格納して「自分たちが所有している」と思い込むからこそ、キャッシュの更新漏れや同期ズレという地獄に直面するのです。

URL StateをStoreに閉じ込めるUXの破壊

検索フィルターやページ番号、開いているタブの情報をZustandなどのStoreに入れている現場も散見されます。これはフロントエンドとして最悪のアンチパターンの1つです。

UIの表示条件をStoreに閉じ込めると、「URLを同僚に共有しても同じ検索結果画面が開けない」「ブラウザの『戻る』ボタンを押しても前のページに戻れない」「ページをリロードした瞬間に検索条件が吹き飛んで1ページ目に戻る」という、Webの基本原則を完全に破壊した粗悪なUXが完成します。

ここまで整理すると、衝撃的な現実に気づくはずです。私たちが日々「グローバル状態管理が必要だ」と叫んでいたデータの95%は、そもそもグローバルStoreに入れるべきものではなかったのです。

3. 処方箋1:Server Stateは「TanStack Query」に完全委譲せよ

Server Stateに対する唯一の正しいアプローチは、「状態としてStoreに保存する」のをやめ、「宣言的なキャッシュマネージャーに購読を委託する」ことです。そのデファクトスタンダードが TanStack Query(旧 React Query) や SWR です。

コード比較:Zustand手動同期 vs TanStack Query宣言的キャッシュ

ユーザー一覧の取得と、新規ユーザー作成後のリスト同期を比較してみましょう。

// ❌ BAD: Zustandで手動管理する場合
// 全画面であらゆるアクションの後に「手動フェッチ」「手動配列差し替え」が必要
const UserList = () => {
  const { users, isLoading, fetchUsers } = useUserStore();
  
  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (isLoading) return <Spinner />;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
};

const CreateUserButton = () => {
  const { fetchUsers } = useUserStore();
  const handleCreate = async () => {
    await api.createUser({ name: 'Charlie' });
    // 別のコンポーネントのために、ここで明示的に再フェッチを呼ばなければ画面が同期しない!
    await fetchUsers();
  };
  return <button onClick={handleCreate}>追加</button>;
};
// ⭕ GOOD: TanStack Queryで宣言的に購読する場合
// コンポーネントはキャッシュキー ['users'] を購読するだけ。Storeは1行も書かない。
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

const UserList = () => {
  const { data: users, isPending } = useQuery({
    queryKey: ['users'],
    queryFn: () => api.getUsers().then(res => res.data),
    staleTime: 1000 * 60 * 5, // 5分間はフレッシュと見なし無駄な通信を完全排除
  });

  if (isPending) return <Spinner />;
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
};

const CreateUserButton = () => {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: (name: string) => api.createUser({ name }),
    onSuccess: () => {
      // キャッシュキー ['users'] を「無効化(Invalidate)」するだけ!
      // 画面上に存在している関連コンポーネントが自動でバックグラウンド再フェッチされる
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return <button onClick={() => mutation.mutate('Charlie')}>追加</button>;
};

「queryKey」と「キャッシュ無効化」がもたらす設計の革命

TanStack Queryを導入すると、コンポーネントは「データをどこに保存するか」を意識する必要が一切なくなります。「自分はこのキャッシュキー(例: ['users'])に紐づくデータを必要としている」と宣言するだけで、ライブラリが裏側で以下のすべてを完璧に処理してくれます。

この時点で、あなたのコードベースから「APIデータを保持していたZustandのStore」は丸ごと消滅します。

4. 処方箋2:UIの表示条件は「URL SearchParams」をSingle Source of Truthにせよ

残りの15%を占める「検索条件、ソート順、ページネーション、タブ選択」。これらはStoreではなく、ブラウザのURL(Search Parameters / クエリ文字列)を唯一の信頼できる情報源(Single Source of Truth)にします。

URLを状態として扱う実践コード(nuqs / useSearchParams)

React/Next.jsにおいて、URLのクエリパラメータを型安全かつ宣言的に扱うためのライブラリ(例えば nuqs)や、ルーターの標準フックを活用した設計パターンを見てみましょう。

// URLを状態として扱うカスタムフックの例(nuqs を利用した型安全URL State)
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs';

export function useArticleFilters() {
  // URLクエリ '?q=react&page=2' と完全連動する状態
  const [query, setQuery] = useQueryState(
    'q',
    parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })
  );
  
  const [page, setPage] = useQueryState(
    'page',
    parseAsInteger.withDefault(1).withOptions({ shallow: true })
  );

  const resetFilters = () => {
    setQuery('');
    setPage(1);
  };

  return { query, setQuery, page, setPage, resetFilters };
}

このカスタムフックを利用するコンポーネントは、まるで通常の useState を扱っているかのようにURLの状態を読み書きできます。

const ArticleSearchPage = () => {
  const { query, setQuery, page, setPage } = useArticleFilters();

  // URLの検索パラメータを TanStack Query の queryKey にそのまま渡す!
  // URLが変われば、自動的に新しいデータがフェッチされ画面が同期する
  const { data, isPending } = useQuery({
    queryKey: ['articles', { query, page }],
    queryFn: () => api.searchArticles({ query, page }),
  });

  return (
    <div>
      <input
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          setPage(1); // 検索文字が変わったら1ページ目へ
        }}
        placeholder="記事を検索..."
      />
      {isPending ? <Spinner /> : <ArticleList items={data.items} />}
      <Pagination current={page} total={data.totalPages} onPageChange={setPage} />
    </div>
  );
};

URLを状態にすることで得られる圧倒的な恩恵

5. 処方箋3:残った「5%の純粋なClient State」をどう扱うか

Server State(80%)をTanStack Queryへ移譲し、UIの表示条件(15%)をURLへ逃がしたとき、あなたの手元に残っている「クライアントが純粋に管理すべき状態」は何でしょうか?

これらはすべて、コンポーネントツリーの局所的なスコープで完結するデータです。グローバルに公開する必要など1ミリもありません。

局所状態には標準の `useState` / `useReducer` で100%事足りる

「でも、親コンポーネントと子コンポーネントでモーダルの開閉フラグを共有したい場合はどうするのか?」という反論があるかもしれません。しかし、それすらも過剰なグローバルStoreの理由にはなりません。

  1. コンポジション(Children / Slot設計)を活用する: 親が状態を持ち、子を children として受け取るだけで、Props Drillingなしに状態を局所化できます。
  2. 極小の React Context を作る: アプリ全体で共有したい「カラーテーマ(Dark / Light)」や「通知トースト(Toast)のキュー」のような、更新頻度が極めて低く局所的な機能に限り、数行のシンプルな React.createContext を定義するだけで十分です。更新頻度が低いため、Context特有の再レンダリング問題も一切起きません。
// グローバルStoreを使わず、極小のContextで完結させるトースト通知の例
import { createContext, useContext, useState, ReactNode } from 'react';

interface ToastContextType {
  showToast: (message: string) => void;
}

const ToastContext = createContext<ToastContextType | null>(null);

export const ToastProvider = ({ children }: { children: ReactNode }) => {
  const [message, setMessage] = useState<string | null>(null);

  const showToast = (msg: string) => {
    setMessage(msg);
    setTimeout(() => setMessage(null), 3000);
  };

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      {message && <div className="toast">{message}</div>}
    </ToastContext.Provider>
  );
};

export const useToast = () => {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error('useToast must be used within ToastProvider');
  return ctx;
};

ご覧の通り、外部ライブラリを何ひとつインストールすることなく、安全で凝集度の高いグローバル通知UIが完成します。

6. まとめ:アーキテクチャの真の進化は「足すこと」ではなく「捨てること」

長年、フロントエンド界隈では「大規模アプリケーションの構築には強力なグローバル状態管理ライブラリが不可欠である」というドグマが信じ込まれてきました。しかし、そのドグマに従って構築された巨大なStoreの正体は、「キャッシュ制御の車輪の再発明」と「URLの劣化コピー」の寄せ集めだったのです。

「Storeレス」アーキテクチャのチェックリスト

この3つの原則を徹底した瞬間、プロジェクトの package.json からRedux、Zustand、Recoilといった依存関係が完全に消滅します。

Storeが消えることで、画面遷移時のゾンビデータは消え去り、手動の同期コードは1行も書く必要がなくなり、バンドルサイズは削ぎ落とされ、テストは圧倒的にシンプルになります。アーキテクチャの洗練とは、新しいツールを増やすことではなく、誤った抽象化を剥ぎ取り、本質的な置き場所へとデータを還流させることなのです。