本文へスキップ
Zustand

Part 9 · state を共有する

50 / 64

Zustand

Context の弱点を、セレクタ 1 行で解く。実務での定番

前の章で、Context には2 つの弱点があると分かりました。 項目単位で購読できないこと、value を毎回作り直してしまうこと。

対処はしましたが、Context を分け、useMemo で包み、memo で囲むという手数が要りました。

実務では、ここで外部のストアを使うことが多いです。 よく使われている Zustand を見ます。

ストアは、コンポーネントの外に置く

import { create } from "zustand";

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",

  increase: () => set((state) => ({ count: state.count + 1 })),
}));

いちばん大きな違いは、Provider が要らないことです。 ファイルの外側に作って、使いたいところで呼ぶだけです。

値と更新のしかたを同じ場所に置くのも特徴です。increase は「1 増やす」という出来事の名前になっています。useReducer でやった「何が起きたかを伝える」と、考え方はそのままです。

store.ts

import { create } from "zustand";

type CounterStore = {
  count: number;
  name: string;
  increase: () => void;
  changeName: () => void;
};

/**
 * ストア。コンポーネントの外に置く。
 *
 * Provider で包む必要がない。
 * 「どこから使うか」を先に決めなくてよいのが、Context との大きな違い。
 */
export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",

  // 更新のしかたも、ストアの中に一緒に置く
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

/*
  中身はまったく同じ、2 つめのストア。

  ふだんは 1 つで足りる。ここで分けているのは、
  このあとの 2 つのデモを別々に動かして見比べるため。
  同じストアを見ていると、片方を押しただけで
  もう片方の数字も動いてしまう。
*/
export const useSelectorStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

そのまま呼ぶと、Context と同じことが起きる

まず、引数なしで呼んでみます。 ストア全体が返ってきます。

const store = useCounterStore();
ストア全体を受け取るうまくいかない例

count を押して、下の 2 つを見る

count だけを使う部品
count: 0
name だけを使う部品
name: さとう

両方とも光ります。前の章で見た「巨大な Context」とまったく同じです。ライブラリを入れただけでは、何も解決しません。

whole-store.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useCounterStore } from "./store";

/** ストア全体を受け取っている。Context にまとめたときと同じことが起きる */
function CountDisplay() {
  const store = useCounterStore();
  return (
    <RenderBox title="count だけを使う部品">count: {store.count}</RenderBox>
  );
}

function NameDisplay() {
  const store = useCounterStore();
  return <RenderBox title="name だけを使う部品">name: {store.name}</RenderBox>;
}

export function WholeStore() {
  const store = useCounterStore();

  return (
    <div className="flex flex-col gap-3">
      <div className="flex flex-wrap gap-2">
        <Button size="sm" onClick={store.increase}>
          count を増やす
        </Button>
        <Button size="sm" variant="outline" onClick={store.changeName}>
          name を変える
        </Button>
      </div>

      <CountDisplay />
      <NameDisplay />
    </div>
  );
}

欲しいものだけを取り出す

Zustand の本体はここです。関数を渡して、必要な部分だけを取り出します

// ✕ 全部もらう。何が変わっても描き直される
const store = useSelectorStore();

// ○ count だけもらう。count が変わったときだけ描き直される
const count = useSelectorStore((state) => state.count);

この関数をセレクタと呼びます。 Zustand は、セレクタが返した値が前と同じかどうかを見て、 違うときだけその部品を描き直します。

セレクタで絞る直した例

同じように count を押してみる

押すだけの部品
count だけを使う部品
count: 0
name だけを使う部品
name: さとう

count の箱だけが光ります。name の箱は動きません。 そして押すだけの部品も動きません。 値を 1 つも読んでいないからです。

更新だけ取り出す

const increase = useSelectorStore((state) => state.increase);

関数はストアが作り直さないので、この部品はストアの変化では描き直されません。 前の章の「操作だけの Context」と同じことが、 セレクタ 1 行でできています。

ただし Part 7 でやったとおり、親が描き直されれば子も描き直されます。 ストアからの変化が届かなくなるだけで、 上から来る描き直しは別の話です。

selector.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useSelectorStore } from "./store";

/**
 * 欲しいものだけを取り出す。
 * この関数(セレクタ)が返した値が変わったときだけ、描き直される。
 */
function CountDisplay() {
  const count = useSelectorStore((state) => state.count);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

function NameDisplay() {
  const name = useSelectorStore((state) => state.name);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

/** 更新するだけの部品。値を 1 つも読んでいないので、何が変わっても動かない */
function Controls() {
  const increase = useSelectorStore((state) => state.increase);
  const changeName = useSelectorStore((state) => state.changeName);

  return (
    <RenderBox title="押すだけの部品">
      <div className="flex flex-wrap gap-2">
        <Button size="sm" onClick={increase}>
          count を増やす
        </Button>
        <Button size="sm" variant="outline" onClick={changeName}>
          name を変える
        </Button>
      </div>
    </RenderBox>
  );
}

export function Selector() {
  return (
    <div className="flex flex-col gap-3">
      <Controls />
      <CountDisplay />
      <NameDisplay />
    </div>
  );
}

あと 3 つ、知っておくと足りる

1. いまの値を読みたいとき(get)

set と一緒に get も受け取れます。更新ではなく、いまの値を読みたいだけのときに使います。

create((set, get) => ({
  items: [],
  // いまの件数を読んでから、判断したい
  addIfRoom: (item) => {
    if (get().items.length >= 10) return;
    set((state) => ({ items: [...state.items, item] }));
  },
}));

set((state) => ...) でも今の値は読めます。get() が要るのは、 更新の外で値を見たいときです。 上の例のように「条件に合わなければ何もしない」と書ける。

2. 通信もストアに置ける

create((set, get) => ({
  status: "editing",

  send: async () => {
    set({ status: "sending" });
    try {
      await fetch("/api/...", { body: JSON.stringify(get().form) });
      set({ status: "done" });
    } catch {
      set({ status: "error" });
    }
  },
}));

ストアの関数はただの関数なので、async にして構いません。 画面側は send() を呼ぶだけになり、成功・失敗の判断はストアが持ちます

3. 閉じても残したいとき(persist)

import { persist } from "zustand/middleware";

type Store = { theme: string; setTheme: (theme: string) => void };

//                    ここが空の () なのが要点 ↓
export const useStore = create<Store>()(
  persist(
    (set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
    { name: "app-theme" },   // localStorage のキー
  ),
);

persist で包むと、中身がブラウザに保存されますブラウザに保存する で扱う話を、ストア全体に対してやるものです。

store.ts

import { create } from "zustand";

type CounterStore = {
  count: number;
  name: string;
  increase: () => void;
  changeName: () => void;
};

/**
 * ストア。コンポーネントの外に置く。
 *
 * Provider で包む必要がない。
 * 「どこから使うか」を先に決めなくてよいのが、Context との大きな違い。
 */
export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",

  // 更新のしかたも、ストアの中に一緒に置く
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

/*
  中身はまったく同じ、2 つめのストア。

  ふだんは 1 つで足りる。ここで分けているのは、
  このあとの 2 つのデモを別々に動かして見比べるため。
  同じストアを見ていると、片方を押しただけで
  もう片方の数字も動いてしまう。
*/
export const useSelectorStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

そしてもう 1 つ、ストアは画面を離れても消えない

ストアはコンポーネントの外にあります。 つまりその画面から離れても、中身はそのまま残ります

useState なら、画面から消えた時点で初期化されていました。 ストアは初期化されません。

  • 利点 入力の続きができる。別の画面から戻っても状態が保たれる
  • 困る点 前回の入力が残ったままフォームが開く、 ログアウトしたのに前の人のデータが見えている

「消したいときは、自分で消す」と覚えてください。 初期状態に戻す関数をストアに用意しておくのが定番です。

const initial = { step: "account", form: null };

create((set) => ({
  ...initial,
  reset: () => set(initial),
}));

store.ts

import { create } from "zustand";

type CounterStore = {
  count: number;
  name: string;
  increase: () => void;
  changeName: () => void;
};

/**
 * ストア。コンポーネントの外に置く。
 *
 * Provider で包む必要がない。
 * 「どこから使うか」を先に決めなくてよいのが、Context との大きな違い。
 */
export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",

  // 更新のしかたも、ストアの中に一緒に置く
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

/*
  中身はまったく同じ、2 つめのストア。

  ふだんは 1 つで足りる。ここで分けているのは、
  このあとの 2 つのデモを別々に動かして見比べるため。
  同じストアを見ていると、片方を押しただけで
  もう片方の数字も動いてしまう。
*/
export const useSelectorStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

Context とどう使い分けるか

Zustand があれば Context が要らなくなる、わけではありません

向いているもの道具
ほとんど変わらない値(テーマ、ログイン中の人、言語)Context
同じ形の画面を、別々の中身で何個も出すContext
よく変わる値を、離れた部品どうしで共有するZustand
サーバーから取ってきたデータどちらでもない(SWR の担当)

1 つのストアが持つ中身は、アプリの中で 1 組だけです (create() を 2 回呼べばストアは 2 つ作れますが、 それぞれの中身は 1 組ずつです)。

だから「同じ画面を 2 つ並べて、それぞれ別の状態を持たせたい」 という使い方には向きません。 Provider を置いた範囲ごとに別々になる Context のほうが向いています。

store.ts

import { create } from "zustand";

type CounterStore = {
  count: number;
  name: string;
  increase: () => void;
  changeName: () => void;
};

/**
 * ストア。コンポーネントの外に置く。
 *
 * Provider で包む必要がない。
 * 「どこから使うか」を先に決めなくてよいのが、Context との大きな違い。
 */
export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",

  // 更新のしかたも、ストアの中に一緒に置く
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

/*
  中身はまったく同じ、2 つめのストア。

  ふだんは 1 つで足りる。ここで分けているのは、
  このあとの 2 つのデモを別々に動かして見比べるため。
  同じストアを見ていると、片方を押しただけで
  もう片方の数字も動いてしまう。
*/
export const useSelectorStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

理解できたか確かめる

確認クイズ

Zustand を入れれば、無駄な描き直しは自動でなくなる?

確認クイズ

「押すだけで表示はしない」ボタンが、何をしても描き直されないのはなぜ?

確認クイズ

ログイン中のユーザー情報は、Zustand と Context のどちらが向いている?

確認クイズ

ログアウトしたのに、前の人の入力が残って見えた。原因は?

selector.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useSelectorStore } from "./store";

/**
 * 欲しいものだけを取り出す。
 * この関数(セレクタ)が返した値が変わったときだけ、描き直される。
 */
function CountDisplay() {
  const count = useSelectorStore((state) => state.count);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

function NameDisplay() {
  const name = useSelectorStore((state) => state.name);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

/** 更新するだけの部品。値を 1 つも読んでいないので、何が変わっても動かない */
function Controls() {
  const increase = useSelectorStore((state) => state.increase);
  const changeName = useSelectorStore((state) => state.changeName);

  return (
    <RenderBox title="押すだけの部品">
      <div className="flex flex-wrap gap-2">
        <Button size="sm" onClick={increase}>
          count を増やす
        </Button>
        <Button size="sm" variant="outline" onClick={changeName}>
          name を変える
        </Button>
      </div>
    </RenderBox>
  );
}

export function Selector() {
  return (
    <div className="flex flex-col gap-3">
      <Controls />
      <CountDisplay />
      <NameDisplay />
    </div>
  );
}

この章のまとめ

  • ストアはコンポーネントの外に置く。Provider は要らない
  • 値と更新のしかたを一緒に置く。 名前は「何が起きたか」で付ける
  • セレクタで絞って初めて、描き直しが減る。 入れただけでは何も変わらない
  • 更新用の関数だけ取り出した部品は、一度も描き直されない
  • ほとんど変わらない値は Context、 よく変わる共有値は Zustand
  • そして共有が必要になるまでは useState

store.ts

import { create } from "zustand";

type CounterStore = {
  count: number;
  name: string;
  increase: () => void;
  changeName: () => void;
};

/**
 * ストア。コンポーネントの外に置く。
 *
 * Provider で包む必要がない。
 * 「どこから使うか」を先に決めなくてよいのが、Context との大きな違い。
 */
export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",

  // 更新のしかたも、ストアの中に一緒に置く
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));

/*
  中身はまったく同じ、2 つめのストア。

  ふだんは 1 つで足りる。ここで分けているのは、
  このあとの 2 つのデモを別々に動かして見比べるため。
  同じストアを見ていると、片方を押しただけで
  もう片方の数字も動いてしまう。
*/
export const useSelectorStore = create<CounterStore>((set) => ({
  count: 0,
  name: "さとう",
  increase: () => set((state) => ({ count: state.count + 1 })),
  changeName: () =>
    set((state) => ({ name: state.name === "さとう" ? "すずき" : "さとう" })),
}));