© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsstate-management

Zustand Test の middleware 連鎖を StateCreator で型崩れなく書く

devtools / persist / subscribeWithSelector / immer を重ねたときに set の引数推論が壊れないよう、StateCreator のジェネリクスで型を補強する手順を解説する。

難易度: 上級·更新: 2026-05-07·
zustand

対応バージョン

nextjs 15react 19zustand 5

前提環境

Zustand の create + middleware を一通り使い、TypeScript ジェネリクスを読めること

概要

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) — 完成例と外部デモ

注意点

Playground 追加記事。slice / immer / persist を組み合わせたときに set の第3引数推論が落ちる問題と、StateCreator の Mutators ジェネリクスで補強する書き方をまとめる。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test の settingsStore に immer を入れて深いネストを楽に更新する

    settingsStore にネストしたフィールドを足すときに、immer middleware で『見た目はミュータブル、実体は immutable』な更新に書き換える手順を解説する。

  • Zustand Test の settingsStore を slice と combine で分割する

    肥大化しがちな store を「フィールドのスライス」「アクションのスライス」に分けて combine で結合し、責務をモジュール単位で表現する手順を解説する。

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の保存処理を非同期 action と loading / error 状態で扱う

    saveSettings のような非同期処理を store のアクションに閉じ込め、status: 'idle'|'loading'|'error'|'success' を 1 つのフィールドで表現する書き方を解説する。

  • Zustand Test の settingsStore を BroadcastChannel で複数タブに同期する

    BroadcastChannel API で別タブの settingsStore に変更をブロードキャストし、エコーバックを避けながら受信側を更新する手順を解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る