概要
Zustand Test の settingsStore は state 3 件・action 3 件の小規模ですが、現実のフォームでは state 10 件・action 10 件 ぐらいに膨らみます。1 ファイルにまとめると create のコールバックが見通しづらくなるため、slice 単位で分割 し combine で結合する書き方を採ります。
この記事で扱うのは次の 3 点。
- slice の最小単位: 「責務のあるフィールド + そのフィールドに対する action」を 1 ファイルに閉じる
combineでひとつの store にまとめる: state 形を渡すだけで型推論が効く- slice 同士の依存: 直接 import するか、外部から関数で渡すか
インストール
npm install zustand
slice の最小単位
slice は「state の一部 + その action」を返す関数です。StateCreator を使うと middleware 連鎖時にも型が壊れません(StateCreator 編 も参照)。
// src/store/slices/identitySlice.ts
import type { StateCreator } from "zustand";
export type IdentitySlice = {
name: string;
email: string;
setName: (name: string) => void;
setEmail: (email: string) => void;
};
export const createIdentitySlice: StateCreator<
IdentitySlice,
[],
[],
IdentitySlice
> = (set) => ({
name: "",
email: "",
setName: (name) => set({ name }),
setEmail: (email) => set({ email }),
});
// src/store/slices/rateSlice.ts
import type { StateCreator } from "zustand";
export type RateSlice = {
rate: number;
setRate: (rate: number) => void;
};
export const createRateSlice: StateCreator<
RateSlice,
[],
[],
RateSlice
> = (set) => ({
rate: 0,
setRate: (rate) => set({ rate: Math.min(100, Math.max(0, rate)) }),
});
各 slice は 自分の type と creator を 1 つずつ export します。テストも slice 単位で書けます。
combine で結合する
combine は state の初期値を受け取り、set / get / store で残りの slice を組み立てる ための middleware です。
// src/store/settingsStore.ts
import { create } from "zustand";
import { combine, devtools, subscribeWithSelector } from "zustand/middleware";
import { createIdentitySlice } from "./slices/identitySlice";
import { createRateSlice } from "./slices/rateSlice";
const INITIAL = {
name: "",
email: "",
rate: 0,
};
export const useSettingsStore = create<
typeof INITIAL & ReturnType<typeof commonActions>
>()(
devtools(
subscribeWithSelector(
combine(INITIAL, (set, get, store) => ({
...createIdentitySlice(set, get, store),
...createRateSlice(set, get, store),
...commonActions(set),
}))
),
{ name: "SettingsStore" }
)
);
function commonActions(set: (v: Partial<typeof INITIAL>) => void) {
return {
reset: () => set(INITIAL),
};
}
combine を使うと 「初期値部分の型」と「アクション部分の型」を別々に書ける ので、SettingsState を手動で定義しなくて済みます。型は typeof INITIAL & ReturnType<...> の交差で表現されます。
slice 同士の依存をどう扱うか
slice A から slice B の値を読みたいときは、get() 経由で同じ store を参照 します。ただし、依存関係が双方向になると追いづらくなるため、依存は 片方向 に限定するのが原則です。
// 例: identitySlice が rateSlice の rate を見たい場合
export const createIdentitySlice: StateCreator<
IdentitySlice & RateSlice, // ← 依存先の型を交差で渡す
[],
[],
IdentitySlice
> = (set, get) => ({
name: "",
email: "",
setName: (name) => {
const rate = get().rate; // ← 別 slice の値を読める
set({ name: rate > 50 ? name.toUpperCase() : name });
},
setEmail: (email) => set({ email }),
});
ここで重要なのは 依存先の型を StateCreator の第 1 ジェネリクスに含める こと。これがないと get().rate で型エラーになります。
どこまで slice にするか
「全部 slice にすべき」ではありません。判断基準は次のとおり。
| 状況 | 推奨 |
|---|---|
| 1 ファイルが 100 行を超え始めた | slice に分け始める |
| 同じグループの state / action が増える | グループ単位で slice 化 |
| 1 つの slice しか触らない機能を追加した | その slice ファイルだけ修正で済む |
| store 全体で 30 行未満 | slice にせず 1 ファイルで OK |
Playground の 4 store のうち、settingsStore だけがこの slice 構成のメリットを享受できる規模になりがちです。uiStore / detailStore は slice にしない方がシンプル に保てます。
ポイント
- slice は 「state の一部 + その action」 を 1 ファイルに閉じる単位。
StateCreatorで型を書く combine(INITIAL, (set, get) => ({ ... }))で初期値とアクションを別々に与え、型の交差で全体を表現する- slice 間の依存は 片方向。
get()経由で別 slice の値を読むときはStateCreatorの型に依存先を含める - すべてを slice にしない。1 ファイル 100 行未満 なら slice にせず読みやすさを優先する
- middleware を被せる場合は
devtools(subscribeWithSelector(combine(...)))の順に重ねる
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — 単一ファイルの基本構成
- StateCreator 編 — middleware 連鎖時の型ヘルパ
- immer 編 — slice の中で深いネストを更新するなら
- Zustand Test (Build) — 完成例と外部デモ