概要
Zustand Test は層が多く(Zod / RHF / settings / profile / detail / ui)、型を雑に書くと宣言が二重三重になります。Playground ではこれを スキーマ 1 か所だけ で定義し、残りはすべて派生で済ませています。
Zod スキーマ (formSchema)
↓ z.infer
FormData
↓ useForm<FormData>()
RHF が register / errors / onSubmit の型を確定
↓ Partial<FormData>
patchFormData(partial) の引数
↓ FormData そのまま
setFormData(values) の引数
↓ FormData & { actions } を組み立て
SettingsState(store の型)
↓ Pick<FormData, "name" | "email">
profileStore の入力型 / detail の派生型
下流で型を変えたくなったら、まずスキーマを直す。これが Playground の鉄則です。
1. Zod スキーマから型を導出する
スキーマは z.coerce.number() を含めて定義し、z.infer で型を取り出します。
// src/schemas/formSchema.ts
import { z } from "zod";
export const formSchema = z.object({
name: z.string().min(1, "名前を入力してください"),
email: z.string().email("有効なメールアドレスを入力してください"),
rate: z.coerce.number().min(0).max(100),
});
// FormData = { name: string; email: string; rate: number }
export type FormData = z.infer<typeof formSchema>;
z.coerce.number() は 入力(input)が unknown、出力(output)が number のスキーマです。z.infer は出力型を返すので、rate は number として下流に流れます。フォーム側は <input type="number"> の文字列を渡すだけで済みます。
2. RHF に型を渡す
useForm<FormData> のジェネリクスにスキーマ由来の型を渡すと、register の補完・errors.<field> の型・handleSubmit の引数型が一括で確定します。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { formSchema, type FormData } from "@/schemas/formSchema";
const {
register,
watch,
handleSubmit,
} = useForm<FormData>({ resolver: zodResolver(formSchema) });
const onSubmit = (data: FormData) => {
// data.name: string / data.rate: number
};
register("rete") のようなタイポはコンパイル時に止まり、フィールド名のリネームを TypeScript が網羅的に追従させてくれます。
3. patchFormData と setFormData の型を分ける
patchFormData は 部分更新、setFormData は 全置換 なので、型もそれに合わせて分けます。Partial<FormData> をそのまま使うのが一番自然です。
import type { FormData } from "@/schemas/formSchema";
type SettingsActions = {
patchFormData: (partial: Partial<FormData>) => void;
setFormData: (values: FormData) => void;
reset: () => void;
};
type SettingsState = FormData & SettingsActions;
呼び出し側は型でしっかり守られます。patchFormData({ name: "foo" }) は通り、setFormData({ name: "foo" }) は email と rate が足りない とコンパイルエラーになります。これが「途中入力」と「確定書き込み」の使い分けを型で表明する仕掛けです。
4. 4 store の型を揃える
すべての store に共通する reset 用の型を interface で抽象化すると、リセット処理を 型レベルで全 store 横断 にできます。
// src/store/types.ts
export type Resettable = { reset: () => void };
// src/store/uiStore.ts
import { create } from "zustand";
import type { Resettable } from "./types";
const INITIAL_UI = { activeTab: "form" as const };
type Tab = "form" | "detail" | "result";
type UiState = { activeTab: Tab; setTab: (t: Tab) => void } & Resettable;
export const useUiStore = create<UiState>((set) => ({
...INITIAL_UI,
setTab: (activeTab) => set({ activeTab }),
reset: () => set(INITIAL_UI),
}));
Resettable を全 store の型に 交差型で混ぜる だけで、「全ストアをリセット」ボタン側は型を見れば各 store が reset を持っていることを確認できます。
5. storeSync の型は Pick で絞る
storeSync は store 間の橋渡しなので、依存範囲を Pick で型に表明します。読み込んでいるフィールドが一目で分かるようになります。
import type { FormData } from "@/schemas/formSchema";
type ProfileInput = Pick<FormData, "name" | "email">;
type RateInput = Pick<FormData, "rate">;
useSettingsStore.subscribe(
(s): ProfileInput => ({ name: s.name, email: s.email }),
({ name, email }) => {
useProfileStore.getState().updateFrom({ name, email });
},
{ equalityFn: shallow }
);
useSettingsStore.subscribe(
(s): RateInput["rate"] => s.rate,
(rate) => {
useDetailStore.getState().setSuggestedRate(Math.max(0, rate * 1.1));
}
);
Pick を介すと、後で email を見るのを止めたいときに型を絞り込むだけで「他の購読が email を参照していないか」を IDE が教えてくれます。
6. selector の戻り値はほぼ常に推論
Zustand 側のジェネリクスから state の型が伝わるため、selector に型を書く必要はほぼありません。
const name = useSettingsStore((s) => s.name);
// name: string
const setFormData = useSettingsStore((s) => s.setFormData);
// setFormData: (values: FormData) => void
const both = useSettingsStore((s) => ({ name: s.name, email: s.email }));
// both: { name: string; email: string }
// ※ オブジェクト selector は useShallow でラップ推奨
selector に注釈を書きたくなったら、それは「スキーマで表せていない概念がある」サインです。スキーマか store の型のどちらかを直すと自然に解決します。
7. middleware を重ねる時は二重括弧
subscribeWithSelector や devtools を被せるなら、create<T>()(middleware(...)) の 二重括弧 を使います。Zustand v5 で middleware を含めて推論を効かせるための記法です。
import { create } from "zustand";
import { devtools, subscribeWithSelector } from "zustand/middleware";
export const useSettingsStore = create<SettingsState>()(
devtools(
subscribeWithSelector((set) => ({
...INITIAL_SETTINGS,
patchFormData: (partial) => set(partial, false, "patchFormData"),
setFormData: (values) => set(values, false, "setFormData"),
reset: () => set(INITIAL_SETTINGS, true, "reset"),
})),
{ name: "SettingsStore" }
)
);
create<T>(...)(一重括弧)に middleware を被せると set の引数推論が落ちることがあります。middleware を1枚でも被せるなら二重括弧 と覚えておけば安全です。
ポイント
- 型の出発点は Zod スキーマ ただ1つ。
z.infer<>でFormDataを取り出して下流に流す - 部分更新は
Partial<FormData>、全置換はFormDataで 型レベルで責務を区別 する Resettableのような共通 trait を交差型で混ぜ、store 間の橋渡しはPick<FormData, "...">で依存範囲を表明すると、横断的な関心を型で押さえられる- selector の戻り値は推論で十分。注釈を書きたくなったらスキーマ側の表現が足りないサイン
- middleware を被せるなら
create<T>()(middleware(...))の 二重括弧記法 で型を維持する
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — 4 store と storeSync の実装
- Zod 編 —
coerceとpatchFormData/setFormDataの使い分け - Zustand Test (Build) — 完成例と外部デモ