概要
Zustand Test の保存ボタンが API を叩く形に拡張すると、isLoading / error / successMessage のような boolean / 文字列フィールド が増えがちです。
それを isLoading: false, error: null のように 個別のフィールドで持つ と、状態の組み合わせが矛盾しやすくなります(例: isLoading: true && error: "...")。
代わりに status を判別ユニオンで 1 本 にすると、ありえない状態が型レベルで排除できます。
type SaveStatus =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "success"; savedAt: number };
本記事はこのパターンを Playground の settingsStore に組み込む書き方を扱います。
インストール
npm install zustand
status を判別ユニオンで持つ
state には saveStatus を 1 本だけ持ち、save action の中で set を呼ぶ場所で kind を遷移 させます。
// src/store/settingsStore.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import type { FormData } from "@/schemas/formSchema";
type SaveStatus =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "success"; savedAt: number };
type SettingsState = FormData & {
saveStatus: SaveStatus;
patchFormData: (partial: Partial<FormData>) => void;
setFormData: (values: FormData) => void;
save: (values: FormData) => Promise<void>;
reset: () => void;
};
const INITIAL = {
name: "",
email: "",
rate: 0,
saveStatus: { kind: "idle" } as SaveStatus,
};
export const useSettingsStore = create<SettingsState>()(
devtools(
(set, get) => ({
...INITIAL,
patchFormData: (partial) => set(partial, false, "patchFormData"),
setFormData: (values) => set(values, false, "setFormData"),
reset: () => set(INITIAL, true, "reset"),
save: async (values) => {
if (get().saveStatus.kind === "loading") return; // 二重送信ガード
set({ saveStatus: { kind: "loading" } }, false, "save:loading");
try {
await postSettings(values);
set(
{
...values,
saveStatus: { kind: "success", savedAt: Date.now() },
},
false,
"save:success"
);
} catch (e) {
set(
{ saveStatus: { kind: "error", message: toMessage(e) } },
false,
"save:error"
);
}
},
}),
{ name: "SettingsStore" }
)
);
function toMessage(e: unknown): string {
return e instanceof Error ? e.message : "保存に失敗しました";
}
ポイントは 3 つ。
saveの 冒頭でloadingチェック を入れて二重送信を防ぐtry / catchの各分岐でsetのアクション名にコロン修飾 (save:loading/save:success/save:error) を付け、devtools のタイムラインで遷移を 1 行ずつ追えるようにするsuccess時に 値の確定 + status 更新 を 1 回のsetでまとめる
UI 側はパターンマッチで分岐する
判別ユニオンを switch するか、status.kind === "..." で分岐します。boolean を 4 つ並べる書き方より読みやすくなります。
"use client";
import { useSettingsStore } from "@/store/settingsStore";
export function SaveStatusView() {
const status = useSettingsStore((s) => s.saveStatus);
switch (status.kind) {
case "idle": return null;
case "loading": return <p>保存中…</p>;
case "error": return <p role="alert" className="text-red-600">{status.message}</p>;
case "success": return (
<p className="text-green-600">
保存しました({new Date(status.savedAt).toLocaleTimeString()})
</p>
);
}
}
status.message は kind: "error" の中でしか使えない、status.savedAt は kind: "success" の中だけ、というのが TypeScript で守られます。
RHF の onSubmit から save を呼ぶ
handleSubmit(onSubmit) の中で save(data) を呼ぶだけです。onSubmit を async にする必要はありますが、戻り値は気にしなくて構いません。
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { formSchema, type FormData } from "@/schemas/formSchema";
import { useSettingsStore } from "@/store/settingsStore";
export function SettingsForm() {
const save = useSettingsStore((s) => s.save);
const status = useSettingsStore((s) => s.saveStatus);
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = async (data: FormData) => {
await save(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* ...入力欄... */}
<button type="submit" disabled={status.kind === "loading"}>
{status.kind === "loading" ? "保存中…" : "保存"}
</button>
</form>
);
}
disabled={status.kind === "loading"} は 二重送信ガードの UI 側 です。store 側のガードと両輪で守ります。
エラーから idle に戻すボタン
エラー後にユーザーがフォームを直して再送できるように、status.kind === "error" のときだけ表示 する「閉じる」ボタンを置きます。
import { useSettingsStore } from "@/store/settingsStore";
export function ErrorBanner() {
const status = useSettingsStore((s) => s.saveStatus);
const set = useSettingsStore.setState; // 直接 setState に置き換え可
if (status.kind !== "error") return null;
return (
<div role="alert" className="rounded border border-red-500 px-3 py-2 text-red-700">
<span>{status.message}</span>
<button onClick={() => set({ saveStatus: { kind: "idle" } })}>閉じる</button>
</div>
);
}
「閉じる」だけなら専用 action を切らず、useSettingsStore.setState を直接呼ぶことも可能です。頻繁に使う遷移なら action 化、稀なら setState 直、で使い分けます。
ポイント
- 非同期 action の状態は 判別ユニオンの
statusで 1 本にまとめる。boolean を散らかさない saveの冒頭でloading判定 を入れて二重送信をガードする。UI 側のdisabledと両輪で守るsetのアクション名はsave:loading/save:success/save:errorのように コロン修飾 で遷移が読めるようにする- UI は
status.kindで switch / 早期 return に分岐する。判別ユニオンが TypeScript で保証してくれる - 稀なフィールド遷移(エラー閉じる等)は専用 action を切らず
setState直 でも OK
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- Zustand 編 — store の基本構成
- RHF 編 —
handleSubmitからsaveを呼ぶ動線 - devtools 命名規則編 —
save:loadingのようなアクション名命名 - Zustand Test (Build) — 完成例と外部デモ