概要
Zustand Test の settingsStore には devtools / persist / subscribeWithSelector / (オプションで)immer まで重なります。何もしないと、middleware を 2 つ以上重ねた時点で set の第 3 引数(アクション名)の型推論が落ちる ことがあります。
これを直すのが StateCreator のジェネリクスです。中身は次のような形をしています。
StateCreator<TState, MutatorsIn, MutatorsOut, U = TState>
| 位置 | 役割 |
|---|---|
TState | この creator が扱う state の全体型 |
MutatorsIn | この creator に 入ってくる時点 で被さっている middleware |
MutatorsOut | この creator が 適用したあと 被さる middleware |
U(4 番目) | この creator が返すスライス(部分的に返すときに使う) |
slice 化やヘルパ化のときに、ここに正しい middleware 型を書けば set の挙動が正確に推論されます。
インストール
npm install zustand
まず壊れる例を見る
middleware を重ねた状態で slice 関数 を切り出すと、set が (values, replace?, action?) => void の 3 引数形式を忘れることがあります。
// ❌ ここで set の3番目の引数が認識されない
function createSettingsCore(set: (v: Partial<SettingsState>) => void) {
return {
setName: (name: string) => set({ name }),
// set({ name }, false, "setName") を書いても型エラー
};
}
set の型を 手で書いている のがダメなところです。StateCreator を使えば middleware 由来の型がそのまま伝わります。
StateCreator で middleware を宣言する
slice creator は StateCreator<...> の 第 2 ジェネリクス に被せる予定の middleware を列挙します。["zustand/devtools", never] のように タグの 2 要素タプル で表現するのが Zustand v5 の流儀です。
import type { StateCreator } from "zustand";
import type { FormData } from "@/schemas/formSchema";
type SettingsState = FormData & {
patchFormData: (partial: Partial<FormData>) => void;
setFormData: (values: FormData) => void;
reset: () => void;
};
type SettingsMutators = [
["zustand/devtools", never],
["zustand/subscribeWithSelector", never],
];
export const createSettings: StateCreator<
SettingsState,
SettingsMutators,
[],
SettingsState
> = (set) => ({
name: "",
email: "",
rate: 0,
patchFormData: (partial) => set(partial, false, "patchFormData"),
setFormData: (values) => set(values, false, "setFormData"),
reset: () => set({ name: "", email: "", rate: 0 }, true, "reset"),
});
set(partial, false, "patchFormData") の 3 番目の引数が型エラーにならない ことが確認できれば成功です。devtools 由来の型が set のシグネチャに乗っているためです。
store 側はそのまま組み立てる
slice creator を create<SettingsState>()(...) の中で middleware と一緒に使います。
// src/store/settingsStore.ts
import { create } from "zustand";
import { devtools, subscribeWithSelector } from "zustand/middleware";
import { createSettings } from "./slices/createSettings";
export const useSettingsStore = create<SettingsState>()(
devtools(
subscribeWithSelector(createSettings),
{ name: "SettingsStore" }
)
);
createSettings を そのまま渡せる のがメリットです。store 側で型を手書きで補強しなくても、middleware 連鎖の型が通ります。
immer も連鎖に入れる
immer を含める場合は Mutators に ["zustand/immer", never] を追加し、creator 内で set の挙動を draft 代入に切り替えます。
import { immer } from "zustand/middleware/immer";
type SettingsMutators = [
["zustand/devtools", never],
["zustand/subscribeWithSelector", never],
["zustand/immer", never],
];
export const createSettings: StateCreator<
SettingsState,
SettingsMutators,
[],
SettingsState
> = (set) => ({
name: "",
email: "",
rate: 0,
patchFormData: (partial) => set((s) => { Object.assign(s, partial); }, false, "patchFormData"),
setFormData: (values) => set((s) => { Object.assign(s, values); }, false, "setFormData"),
reset: () => set(() => ({ name: "", email: "", rate: 0 }), true, "reset"),
});
export const useSettingsStore = create<SettingsState>()(
devtools(
subscribeWithSelector(immer(createSettings)),
{ name: "SettingsStore" }
)
);
middleware の 重ね順 と タプルの並び は対応していますが、逆順 に書くケース・タプルの末尾を never にする などの細部はバージョンごとに変わるため、set の型を IDE で確認しながら調整するのが安全です。
部分的なスライスを返す書き方
createIdentitySlice のように state の一部だけ返す creator は、4 番目のジェネリクス U を絞り込んで使います。
type IdentitySlice = Pick<SettingsState, "name" | "email"> & {
setName: (name: string) => void;
setEmail: (email: string) => void;
};
export const createIdentitySlice: StateCreator<
SettingsState, // ← store 全体の型
SettingsMutators,
[],
IdentitySlice // ← この creator が返す部分
> = (set) => ({
name: "",
email: "",
setName: (name) => set({ name }, false, "setName"),
setEmail:(email) => set({ email }, false, "setEmail"),
});
slice 同士で 依存先の型を共有 したいときは slice / combine 編 で書いた要領で第 1 ジェネリクスに依存先の型を交差させます。
ポイント
- middleware を 2 つ以上重ねたら
StateCreatorのMutatorsジェネリクス を必ず書く Mutatorsは["zustand/devtools", never]のような タプルの配列。被さる順に並べる- 部分スライスを返す場合は 第 4 ジェネリクス
Uにスライスの型を渡す setの 3 引数(アクション名)が補完で出るか を IDE で確認するのが、型が通っているかの目安immerを入れる場合は creator 内で draft への代入 に切り替える。set((s) => { s.x = ... })の形
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- slice / combine 編 — slice 単位での creator 設計
- immer 編 — draft 代入の書き方
- persist 編 — persist を含めるときの型
- Zustand Test (Build) — 完成例と外部デモ