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 つを見る
両方とも光ります。前の章で見た「巨大な 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 の箱だけが光ります。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 === "さとう" ? "すずき" : "さとう" })),
}));