React状態管理の深掘り #3 Zustandで扱う軽量なクライアント状態
#2では、サーバー状態をTanStack Queryに切り出しました。そうすると、グローバルに共有する状態として残るのは本物のクライアント状態だけです。ダークモード、サイドバーの開閉、ログインしたユーザー情報、カートのように、ブラウザの中で完結する値です。この記事では、そのクライアント状態を最も軽く扱うZustandを見ていきます。
useContextでは何が物足りないのか #
基礎講座 #12で、useContextでグローバルな値を共有する方法を学びました。小さな値1つなら十分ですが、アプリが大きくなると2つが気になります。
- Providerのネスト — 共有する状態のまとまりが増えるたびに、Providerがツリーの上のほうに一枚ずつ積み重なります。
- 不要な再レンダリング — Contextの値が変わると、その値の一部だけを使うコンポーネントまで、Provider配下の全体が再レンダリングされやすいです。
Zustandはこの2つを正面から解決します。Providerがなく、必要な値だけを選んで購読するので、その値が変わるときだけ再レンダリングされます。
インストールと最初のストア #
npm install zustandcreateでストアを作ります。状態と、その状態を変える関数を1つのオブジェクトに一緒に入れるのが特徴です。
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でツリーを包む必要がありません。
コンポーネントで使う #
作ったストアは、ただフックのように呼び出します。
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 }),
}));function ThemeBadge() {
// themeだけを購読 → isSidebarOpenが変わってもこのコンポーネントは再レンダリングされない
const theme = useUiStore((state) => state.theme);
return <span>現在のテーマ: {theme}</span>;
}ThemeBadgeはthemeだけを購読するので、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に自動で保存し、復元します。
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は状態を小さな原子に分けて組み立てるボトムアップです。