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

サンプルガイド
←サンプル一覧
nextjsform

Zustand Test の型を Zod スキーマから 4 ストアまで貫く

Zod スキーマ → z.infer → useForm → settings / profile / detail / ui の 4 ストアまで、Playground の TypeScript の流れを1ステップずつ追う。

難易度: 初級·更新: 2026-05-07·
zodreact-hook-formzustand

対応バージョン

nextjs 15react 19zod 4react-hook-form 7zustand 5

前提環境

TypeScript の型エイリアス・ジェネリクス・Partial / Pick の基本を理解していること

概要

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

注意点

Playground の3記事のうち TypeScript 編。スキーマ 1 か所だけで型を定義し、その派生で 4 ストア・RHF・selector の型を全部解決する設計を読み解く。

リポジトリを見る

関連サンプル

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

  • Zustand Test の React Hook Form を watch・handleSubmit・formState で読み解く

    useForm の watch でリアルタイム同期し、handleSubmit で確定値を扱い、formState で UI を切り替える Playground の RHF 実装を1ステップずつ解説する。

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

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

  • Zustand Test の Zod / RHF watch / patchFormData と setFormData を読み解く

    RHF の watch でリアルタイム同期しつつ、保存時に Zod parse で確定値を書き戻す Playground のフォーム実装を1ステップずつ解説する。

  • Zustand Test のフォームエラーを aria-invalid と aria-describedby で読み上げ可能にする

    RHF の errors.<field>.message を、aria-invalid と aria-describedby で input と紐付けてスクリーンリーダーに伝える書き方を解説する。

  • Zustand Test の Form / Monitor / TabBar を Storybook で分離する

    Zustand に依存する UI を Storybook で取り扱うために、store のリセット / decorator / play function を組み合わせて状態をストーリー単位で固定する手順を解説する。

関連仕様

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

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
  • LibraryZodTypeScript ファーストのスキーマバリデーションライブラリ。型推論と実行時バリデーションを同一スキーマで行える。
←サンプル一覧に戻る