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大バグの嵐
- ① Stale Data(腐ったデータ)と画面間不整合: モーダルでプロフィール名を「Alice」から「Bob」に更新したのに、ヘッダー右上のアバター横は「Alice」のまま。なぜならヘッダーは別のセレクタやStoreを見ており、手動の再取得命令が届いていないからです。
- ② アンマウント時のゾンビステート(残留ゴミ): 「画面離脱後もStoreの配列にデータが残り続け、次回その画面を開いた瞬間に一瞬だけ前回の古いデータがフラッシュ表示される」。これを防ぐために、現場では
useEffect(() => { return () => resetStore(); }, [])という不毛なクリーンアップコードが全画面に散らばります。 - ③ キャッシュ機能の自前車輪の再発明: 重複リクエストの抑制(Deduplication)、タブ切り替え時の自動再取得(Window Focus Refetching)、有効期限切れ(TTL)、通信エラー時の指数バックオフリトライ……。これらをZustandやReduxの非同期アクションの中で手動実装しようとして、ライブラリ本来の責務を超えた肥大化した怪物コードが生まれます。
- ④ レースコンディション(競合状態): ユーザーが検索バーで文字を高速に入力した際、先に出したリクエスト(遅延大)が後から返ってきたリクエスト(遅延小)を上書きし、画面が古い検索結果で確定してしまう事故が日常茶飯事になります。
「なぜこれほど慎重に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'])に紐づくデータを必要としている」と宣言するだけで、ライブラリが裏側で以下のすべてを完璧に処理してくれます。
- 同一画面で10個のコンポーネントが同じ
['users']を呼んでも、APIリクエストは自動的に1回にマージされる(Request Deduplication)。 - ユーザーが別のタブに切り替えて戻ってきた際、自動的に最新データを取得して画面を更新する(Focus Refetching)。
- 更新が発生したら
invalidateQueriesを呼ぶだけで、アクティブな画面上のコンポーネントだけが自動再描画される。非アクティブな画面のキャッシュは「期限切れ」のマークが付き、次回表示された時にだけ取得されるため、無駄なリクエストもメモリゴミもゼロ。
この時点で、あなたのコードベースから「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を状態にすることで得られる圧倒的な恩恵
- 完全なURL共有性: 複雑に絞り込んだ検索結果のURLをSlackに貼るだけで、チームメンバー全員が寸分違わず同じ画面を即座に開けます。
- ネイティブなブラウザバック耐性: ユーザーが検索結果から詳細画面に遷移し、ブラウザの「戻る」ボタンを押した瞬間、前の検索文字とページ番号がそのまま完全に復元されます。
- マルチウィンドウ・別タブ耐性: 別タブで異なる検索条件を開いても、Storeのように状態が混ざり合って画面が壊れる事故が物理的に起きません。
5. 処方箋3:残った「5%の純粋なClient State」をどう扱うか
Server State(80%)をTanStack Queryへ移譲し、UIの表示条件(15%)をURLへ逃がしたとき、あなたの手元に残っている「クライアントが純粋に管理すべき状態」は何でしょうか?
- アコーディオンが開いているか閉じているか
- モーダルダイアログが表示されているか
- ドラッグ&ドロップ操作中の座標
- 送信ボタンを押す前の入力フォームのローカル値
これらはすべて、コンポーネントツリーの局所的なスコープで完結するデータです。グローバルに公開する必要など1ミリもありません。
局所状態には標準の `useState` / `useReducer` で100%事足りる
「でも、親コンポーネントと子コンポーネントでモーダルの開閉フラグを共有したい場合はどうするのか?」という反論があるかもしれません。しかし、それすらも過剰なグローバルStoreの理由にはなりません。
- コンポジション(Children / Slot設計)を活用する: 親が状態を持ち、子を
childrenとして受け取るだけで、Props Drillingなしに状態を局所化できます。 - 極小の 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レス」アーキテクチャのチェックリスト
- そのデータはサーバーから取得したものか? →
useQueryに任せよ。Storeには1バイトも入れるな。 - そのデータはユーザーが共有・リロードした時に保持されるべきか? →
URL SearchParamsに置け。ブラウザを信頼せよ。 - そのデータはUIの局所的なアニメーションや一時入力か? → 単なるコンポーネントの
useStateで閉じ込めよ。
この3つの原則を徹底した瞬間、プロジェクトの package.json からRedux、Zustand、Recoilといった依存関係が完全に消滅します。
Storeが消えることで、画面遷移時のゾンビデータは消え去り、手動の同期コードは1行も書く必要がなくなり、バンドルサイズは削ぎ落とされ、テストは圧倒的にシンプルになります。アーキテクチャの洗練とは、新しいツールを増やすことではなく、誤った抽象化を剥ぎ取り、本質的な置き場所へとデータを還流させることなのです。