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

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

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

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

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

対応バージョン

nextjs 15react 19zustand 5

前提環境

Zustand の create と action / set / get の基本を理解していること

概要

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

注意点

Playground 追加記事。boolean を散らかさず status の判別ユニオンで持つ書き方と、二重送信を防ぐガードをまとめる。

リポジトリを見る

関連サンプル

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

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

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

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

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

  • Zustand Test の suggestedRate を subscribeWithSelector で派生する

    settingsStore.rate から detailStore.suggestedRate を導出する派生状態の作り方を、subscribeWithSelector と shallow を軸に解説する。

  • Zustand Test の devtools をアクション命名規則で読みやすくする

    store 名 / アクション名 / replace モードを揃え、Redux DevTools のタイムラインを 4 store 横断で読み解けるようにする命名規則を解説する。

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

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

関連仕様

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

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