ReactでZustandを使い、小さなストアで状態を共有する方法
いくつかのコンポーネントで同じ状態を使いたいとき、Zustand なら create でストアを1つ作り、それをフックとして呼ぶだけで済みます。Context のように Provider で包む必要はありません。値を取り出すときにセレクターで必要な値だけを選ぶと、その値が変わったコンポーネントだけが描画し直されます。
筆者は検証環境で、猫の名前とごはんの回数を持つ小さなストアを作り、型チェックの結果と、Chromium での再描画の回数、ストアをコンポーネントの外から使ったときの動きを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは zustand 5.0.16、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。型チェックは tsc、ブラウザでの確認は、esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作して行いました。
ストアを作る
ストアには、状態と、状態を変える関数(アクション)を一緒に入れます。TypeScript では、create(...) ではなく create<CatState>()(...) と書きます。型引数のあとに、空の () が1つ入るのが決まりです。
import { create } from 'zustand';
type CatState = {
name: string;
meals: number;
feed: () => void;
rename: (name: string) => void;
reset: () => void;
};
export const useCatStore = create<CatState>()((set) => ({
name: 'たま',
meals: 0,
feed: () => set((state) => ({ meals: state.meals + 1 })),
rename: (name) => set({ name }),
reset: () => set({ name: 'たま', meals: 0 }),
}));
set には、変えたい項目だけを渡します。今の値をもとにするときは、feed のように関数を渡します。
型を書かずに create((set) => ({ ... })) とすると、strict を有効にした検証環境では、set に渡した関数の state が型の付かない引数になり、エラーになりました。
errors/NoCurry.ts(5,19): error TS7006: Parameter 'state' implicitly has an 'any' type.
Zustand の TypeScript のガイドによると、状態の型は初期値から推論できないので、<CatState> と自分で書きます。そのあとに () が入るのは、状態の型だけを指定し、ほかの型引数は推論に任せるための書き方です。
型を付けておくと、項目の名前を間違えたときに型チェックでエラーになり、気づけます。set({ nam: name }) と書くと、次のエラーになりました(型の部分は短くしています)。
errors/WrongKey.ts(12,27): error TS2561: Object literal may only specify known properties, but 'nam' does not exist in type 'CatState | Partial<CatState> | ...'. Did you mean to write 'name'?
セレクターで、必要な値だけを読む
コンポーネントでは、useCatStore に「ストアから何を取り出すか」を決める関数(セレクター)を渡します。Provider で包む必要はありません。
function MealCount() {
const meals = useCatStore((state) => state.meals);
return <p>ごはん:{meals}回</p>;
}
function NameTag() {
const name = useCatStore((state) => state.name);
return <p>名前:{name}</p>;
}
function WholeState() {
const state = useCatStore();
return <p>全部:{state.name}・{state.meals}回</p>;
}
function Buttons() {
const feed = useCatStore((state) => state.feed);
const rename = useCatStore((state) => state.rename);
return (
<div>
<button onClick={feed}>ごはんをあげる</button>
<button onClick={() => rename('みけ')}>名前を「みけ」にする</button>
</div>
);
}
検証では、それぞれのコンポーネントの中で、描画された回数を数えて画面に出しました(上のコードでは、数える部分を省いています)。4つを並べて、「ごはんをあげる」を3回、「名前を「みけ」にする」を1回押した結果です。
| コンポーネント | 選んだ値 | 最初 | ごはんを3回の後 | 名前を変えた後 |
|---|---|---|---|---|
| MealCount | meals |
1回 | 4回 | 4回 |
| NameTag | name |
1回 | 1回 | 2回 |
| WholeState | ストア全体 | 1回 | 4回 | 5回 |
| Buttons | feed と rename |
1回 | 1回 | 1回 |

MealCount はごはんのときだけ、NameTag は名前のときだけ描画し直されました。アクションを選んだ Buttons は、一度も描画し直されていません。アクションの関数は、状態が変わっても同じものが残るためです。
セレクターを渡さずに useCatStore() とした WholeState は、どちらの変更でも描画し直されました。Zustand の README にも、ストア全体を取り出すと、状態が変わるたびに描画し直されると書かれています。
さらに、名前が「みけ」のまま、同じボタンをもう一度押しました。NameTag は2回のままでしたが、WholeState は6回になりました。set を呼ぶと、値が同じでもストアの状態は新しいオブジェクトになります。そのため、ストア全体を選んだコンポーネントは描画し直されます。
複数の値をまとめて選ぶときは useShallow で包む
名前とごはんの回数を、オブジェクトにまとめて1回で取り出したくなることがあります。
export function BadPicker() {
const { name, meals } = useCatStore((state) => ({ name: state.name, meals: state.meals }));
return <p>{name}・{meals}回</p>;
}
この書き方は、Zustand 5 では無限ループになることがあります。検証環境でも無限ループになり、画面に何も表示されず、コンソールに次のエラーが出ました。検証では、createRoot の onUncaughtError で受け取ったエラーをログに出しています。そのため、2行目の頭に onUncaughtError: が付いています(React DevTools の案内の行は省いています)。
[error] The result of getSnapshot should be cached to avoid an infinite loop
[log] onUncaughtError: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
Zustand は、React の useSyncExternalStore でストアの値を読んでいます(Zustand のソースコード(react.ts))。Zustand 5 の既定の比べ方は Object.is なので、セレクターが呼ばれるたびに新しいオブジェクトを返すと、毎回「値が変わった」と判断されます。1行目の警告の文面は、インストールした react-dom の中にあり、Zustand の中にはありませんでした。v5 への移行ガイドにも、新しい参照を返すセレクターは無限ループになりうると書かれています。
直すには、セレクターを useShallow で包みます。useShallow は、セレクターが返したオブジェクトの各プロパティを1段だけ比べ、同じなら前の結果を返します。
import { useShallow } from 'zustand/react/shallow';
export function GoodPicker() {
const { name, meals } = useCatStore(
useShallow((state) => ({ name: state.name, meals: state.meals })),
);
return <p>{name}・{meals}回</p>;
}
こちらも検証では描画回数を数えました(上のコードでは省いています)。エラーは出ず、「たま・0回」と表示されました。feed() を呼ぶと描画が2回目になり、そのあと rename('たま') で同じ名前を入れ直しても、描画は2回のままでした。
セレクターは、値を1つずつ取り出すのが基本です。オブジェクトや配列にまとめて返すときは、useShallow で包みます。
set がマージするのは1段目だけ
set に渡した項目は、今の状態にマージされます。Node.js でストアを直接操作して確かめました。setState({ name: 'みけ' }) のあとも、meals やアクションの feed は残っていました。
ただし、マージされるのは1段目だけです。Zustand のガイドにも、入れ子のオブジェクトは自分でマージするように書かれています。profile: { name, age } という入れ子の状態に、{ profile: { name: 'みけ' } } を渡すと、age が消えました(型エラーになるので、検証ではキャストして渡しています)。
# 入れ子を一部だけ渡した後: {"profile":{"name":"みけ"}}
# スプレッドで入れ子を作り直した後: {"profile":{"name":"みけ","age":3}}
型を付けていれば、キャストしない限り、このような渡し方は型チェックでエラーになります。
errors/Nested.ts(5,28): error TS2741: Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: number; }'.
入れ子を変えるときは、set((state) => ({ profile: { ...state.profile, name: 'みけ' } })) のように、スプレッドで作り直します。
set や setState の第2引数に true を渡すと、マージではなく置き換えになります。型を付けたストアでは、アクションを含まないオブジェクトを渡すと型エラーになります。検証でキャストして setState({ name: 'くろ', meals: 0 }, true) を実行すると、アクションの feed も消えて undefined になりました。
コンポーネントの外から読み書きする
create が返したフックには、getState、setState、subscribe が付いています。React のコンポーネントの外でも、ストアを読み書きできます。
const unsubscribe = useCatStore.subscribe((state, prev) => {
console.log(`# subscribe: meals ${prev.meals} -> ${state.meals}, name ${prev.name} -> ${state.name}`);
});
useCatStore.getState().feed();
useCatStore.getState().feed();
useCatStore.setState({ name: 'みけ' });
unsubscribe();
Node.js で実行すると、変更のたびに subscribe の関数が呼ばれ、変更前と変更後の状態を受け取れました。
# subscribe: meals 0 -> 1, name たま -> たま
# subscribe: meals 1 -> 2, name たま -> たま
# subscribe: meals 2 -> 2, name たま -> みけ
アクションもストアの一部なので、getState().feed() のように呼べます。
小さなストアをフックとして呼ぶ
Zustand では、create<型>()(...) で状態とアクションを1つのストアにまとめ、コンポーネントからはセレクターで必要な値だけを読みます。検証では、選んだ値が変わったコンポーネントだけが描画し直されました。オブジェクトにまとめて選ぶときは useShallow で包むこと、set のマージが1段目だけであることに気をつければ、Provider なしで小さな状態を共有できます。





