React状態管理の深掘り #3 Zustandで扱う軽量なクライアント状態

読了 6分

#2では、サーバー状態をTanStack Queryに切り出しました。そうすると、グローバルに共有する状態として残るのは本物のクライアント状態だけです。ダークモード、サイドバーの開閉、ログインしたユーザー情報、カートのように、ブラウザの中で完結する値です。この記事では、そのクライアント状態を最も軽く扱うZustandを見ていきます。

useContextでは何が物足りないのか #

基礎講座 #12で、useContextでグローバルな値を共有する方法を学びました。小さな値1つなら十分ですが、アプリが大きくなると2つが気になります。

  • Providerのネスト — 共有する状態のまとまりが増えるたびに、Providerがツリーの上のほうに一枚ずつ積み重なります。
  • 不要な再レンダリング — Contextの値が変わると、その値の一部だけを使うコンポーネントまで、Provider配下の全体が再レンダリングされやすいです。

Zustandはこの2つを正面から解決します。Providerがなく必要な値だけを選んで購読するので、その値が変わるときだけ再レンダリングされます。

インストールと最初のストア #

インストール
npm install zustand

createでストアを作ります。状態と、その状態を変える関数を1つのオブジェクトに一緒に入れるのが特徴です。

store/useCounterStore.js
import { create } from "zustand";

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

setは状態を更新する関数です。useReducerと違い、返したオブジェクトを既存の状態に浅くマージします。なのでcountだけ返しても、残りのフィールドはそのまま保たれます。

ここで注目すべきは、このストアがコンポーネントの外に住むという事実です。Providerでツリーを包む必要がありません。

コンポーネントで使う #

作ったストアは、ただフックのように呼び出します。

Counter.jsx
import { useCounterStore } from "./store/useCounterStore";

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

useCounterStore((state) => state.count)のように関数を渡す部分をセレクター(selector)と呼びます。「このストアからcountだけを購読する」という意味です。ツリーのどのコンポーネントから呼んでも、同じストアを指すので値が共有されます。propsで渡さなくて済みます。

セレクターが再レンダリングを分ける地点 #

Zustandの性能上の利点は、このセレクターにあります。セレクターが返した値が変わるときだけ、そのコンポーネントが再レンダリングされます。

ストアに値が複数あるとしてみます。

複数の値があるストア
const useUiStore = create((set) => ({
  isSidebarOpen: false,
  theme: "light",
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
  setTheme: (theme) => set({ theme }),
}));
themeだけを購読するコンポーネント
function ThemeBadge() {
  // themeだけを購読 → isSidebarOpenが変わってもこのコンポーネントは再レンダリングされない
  const theme = useUiStore((state) => state.theme);
  return <span>現在のテーマ: {theme}</span>;
}

ThemeBadgethemeだけを購読するので、isSidebarOpenがいくらトグルされても描き直されません。useContextだったら、同じContextの値が変わるときに一緒に再レンダリングされていた部分です。この精密さが、Zustandが軽い理由です。

注記
複数の値を一度に取り出そうとしてオブジェクトを返すセレクター((s) => ({ a: s.a, b: s.b }))を使うと、毎回新しいオブジェクトが作られて不要な再レンダリングが生じることがあります。このときは値をそれぞれ別に購読するか、Zustandが提供するuseShallowで浅い比較を適用します。

コンポーネントの外からもアクセスする #

ストアがReactの外にあるので、コンポーネントでない場所からも状態を読み書きできます。

コンポーネントの外でストアを扱う
// 現在の値を読む(購読なしで一度だけ読む)
const current = useCounterStore.getState().count;

// 値を変える
useCounterStore.getState().increment();

// 変化を購読する
const unsubscribe = useCounterStore.subscribe((state) =>
  console.log("countが変わった:", state.count)
);

イベントハンドラの外のユーティリティ関数、ルーターガード、通常のモジュールでグローバル状態が必要なときに役立ちます。

persist — リロードしても保つ #

ダークモード設定やログイン状態のように、リロード後も残るべき値があります。Zustandのpersistミドルウェアが、ストアをlocalStorageに自動で保存し、復元します。

persistミドルウェア
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useThemeStore = create(
  persist(
    (set) => ({
      theme: "light",
      toggleTheme: () =>
        set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
    }),
    { name: "theme-storage" } // localStorageのキー名
  )
);

基礎講座のTodoアプリlocalStorageの保存と復元を手で書いたことを思い出すと、このミドルウェアがその仕事を丸ごと代わりにやってくれます。

Zustandがよく合う状況 #

  • グローバルなクライアント状態が必要だが、Reduxは重く感じるとき。 APIが小さく、ボイラープレートがほとんどありません。
  • Providerのネストを増やしたくないとき。 ストアがReactの外にあり、ツリーを包みません。
  • 再レンダリングを精密に制御したいとき。 セレクター単位で購読が切れます。

逆に、サーバーデータはここに入れないでください。 #1で強調したとおり、サーバー状態は#2のTanStack Queryに任せ、Zustandにはクライアント状態だけを置くほうがシンプルです。

まとめ #

Zustandは、最も少ないコードでグローバルなクライアント状態を共有するツールです。

  • create((set) => ({ ... })) — 状態と更新関数を1つのオブジェクトに、Provider不要
  • セレクター useStore((s) => s.x) — 必要な値だけ購読、その値が変わるときだけ再レンダリング
  • getState / subscribe — コンポーネントの外からもアクセス
  • persistミドルウェア — localStorageの保存と復元を自動化

次回「React状態管理の深掘り #4 Jotaiと原子(atom)モデル」では、同じクライアント状態をまったく異なる角度からアプローチします。Zustandが1つのストアに状態を集めるトップダウンなら、Jotaiは状態を小さな原子に分けて組み立てるボトムアップです。

X