ReactでZustandを使い、小さなストアで状態を共有する方法

​​React​TypeScript,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つ入るのが決まりです。

ts
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 が型の付かない引数になり、エラーになりました。

text
errors/NoCurry.ts(5,19): error TS7006: Parameter 'state' implicitly has an 'any' type.

Zustand の TypeScript のガイドによると、状態の型は初期値から推論できないので、<CatState> と自分で書きます。そのあとに () が入るのは、状態の型だけを指定し、ほかの型引数は推論に任せるための書き方です。

型を付けておくと、項目の名前を間違えたときに型チェックでエラーになり、気づけます。set({ nam: name }) と書くと、次のエラーになりました(型の部分は短くしています)。

text
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 で包む必要はありません。

tsx
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回
検証環境のChromiumで、「ごはん:3回(描画 4回)」「名前:みけ(描画 2回)」「全部:みけ・3回(描画 5回)」の3行と、「ごはんをあげる」「名前を「みけ」にする」のボタン、「ボタンの描画 1回」が表示された画面
ごはんを3回あげ、名前を「みけ」にした後の描画回数

MealCount はごはんのときだけ、NameTag は名前のときだけ描画し直されました。アクションを選んだ Buttons は、一度も描画し直されていません。アクションの関数は、状態が変わっても同じものが残るためです。

セレクターを渡さずに useCatStore() とした WholeState は、どちらの変更でも描画し直されました。Zustand の README にも、ストア全体を取り出すと、状態が変わるたびに描画し直されると書かれています。

さらに、名前が「みけ」のまま、同じボタンをもう一度押しました。NameTag は2回のままでしたが、WholeState は6回になりました。set を呼ぶと、値が同じでもストアの状態は新しいオブジェクトになります。そのため、ストア全体を選んだコンポーネントは描画し直されます。

複数の値をまとめて選ぶときは useShallow で包む

名前とごはんの回数を、オブジェクトにまとめて1回で取り出したくなることがあります。

tsx
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 の案内の行は省いています)。

text
[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段だけ比べ、同じなら前の結果を返します。

tsx
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 が消えました(型エラーになるので、検証ではキャストして渡しています)。

text
# 入れ子を一部だけ渡した後: {"profile":{"name":"みけ"}}
# スプレッドで入れ子を作り直した後: {"profile":{"name":"みけ","age":3}}

型を付けていれば、キャストしない限り、このような渡し方は型チェックでエラーになります。

text
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 のコンポーネントの外でも、ストアを読み書きできます。

ts
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 の関数が呼ばれ、変更前と変更後の状態を受け取れました。

text
# subscribe: meals 0 -> 1, name たま -> たま
# subscribe: meals 1 -> 2, name たま -> たま
# subscribe: meals 2 -> 2, name たま -> みけ

アクションもストアの一部なので、getState().feed() のように呼べます。

小さなストアをフックとして呼ぶ

Zustand では、create<型>()(...) で状態とアクションを1つのストアにまとめ、コンポーネントからはセレクターで必要な値だけを読みます。検証では、選んだ値が変わったコンポーネントだけが描画し直されました。オブジェクトにまとめて選ぶときは useShallow で包むこと、set のマージが1段目だけであることに気をつければ、Provider なしで小さな状態を共有できます。

​Posted by 86