本文へスキップ
申込フォームを作る

Part 11 · まとめて作る

62 / 64

申込フォームを作る

項目が増えたフォームを、reducer とライブラリで受け止める

最後は申込フォームです。3 画面に分かれていて、 最後にサーバーへ送ります。

この章がいちばん重いですが、難しい概念は 1 つも出てきません。 重いのは、これまでの道具がいっぺんに必要になるからです。

項目が増えると、何が起きるか

まず、項目を useState で 1 つずつ持ってみます。 6 項目なら 6 行です。

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [age, setAge] = useState("");
const [zip, setZip] = useState("");
const [note, setNote] = useState("");
6 項目を useState で持った版うまくいかない例

どこか 1 つに打って、3 つの箱を見る

どこか 1 つに打って、下の 3 つの箱を見てください

項目 1・2
項目 3・4
項目 5・6

1 文字打つだけで、3 つの箱すべてが光ります。打っていない項目まで、毎回描き直されています。 Part 7 でやったとおり、 state を持っているのがいちばん上だからです。

6 項目ならまだ気になりません。 ですが実務のフォームは 20 項目を超えることがあります。1 文字ごとに 20 項目ぶん描き直すと、 さすがに引っかかりを感じ始めます。

many-usestates.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";

/** 6 項目を useState で 1 つずつ持った版 */
export function ManyUseStates() {
  useTrackDemoRender();

  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [age, setAge] = useState("");
  const [zip, setZip] = useState("");
  const [note, setNote] = useState("");

  const fields = [
    { label: "メールアドレス", value: email, set: setEmail },
    { label: "パスワード", value: password, set: setPassword },
    { label: "名前", value: name, set: setName },
    { label: "年齢", value: age, set: setAge },
    { label: "郵便番号", value: zip, set: setZip },
    { label: "備考", value: note, set: setNote },
  ];

  return (
    <div className="flex flex-col gap-3">
      <p className="text-sm text-muted-foreground">
        どこか 1 つに打って、下の 3 つの箱を見てください
      </p>

      {[0, 1, 2].map((group) => (
        <RenderBox key={group} title={`項目 ${group * 2 + 1}・${group * 2 + 2}`}>
          <div className="flex flex-col gap-2">
            {fields.slice(group * 2, group * 2 + 2).map((field) => (
              <Input
                key={field.label}
                placeholder={field.label}
                aria-label={field.label}
                value={field.value}
                onChange={(event) => field.set(event.target.value)}
              />
            ))}
          </div>
        </RenderBox>
      ))}

      <Button
        size="sm"
        variant="outline"
        onClick={() => fields.forEach((field) => field.set(""))}
      >
        全部消す
      </Button>
    </div>
  );
}

「何が起きたか」で書き直す

画面の状態をひとまとめにして、起きたことの名前で更新します。 置き場所は Zustand のストアにします。

export const useFormStore = create((set, get) => ({
  step: "account",
  account: null,
  profile: null,
  status: "editing",

  submitAccount: (values) => set({ account: values, step: "profile" }),
  submitProfile: (values) => set({ profile: values, step: "confirm" }),
  goBack: () => set(...),
  send: async () => { ... },
}));

setStepsetAccount のような setter を並べていないところが要点です。 並べてしまうと、「次へ進む」が呼び出し側の 2 行の組み合わせになり、 片方を忘れた瞬間に画面が壊れます。

// ✕ 呼ぶ側が 2 つを正しく組み合わせる必要がある
setAccount(values);
setStep("profile");

// ○ 起きたことを 1 つ伝えるだけ
submitAccount(values);

「アカウントが入力された」なら、保存して次へ進むのは決まりきっています。 その決まりをストアの中に閉じ込めれば、 呼ぶ側が間違えようがありません。

form-store.ts

import { create } from "zustand";

/** 申込の 3 段階。文字列を直接書かず、この 3 つに絞る */
export type Step = "account" | "profile" | "confirm";

export const steps: Step[] = ["account", "profile", "confirm"];

export const stepLabels: Record<Step, string> = {
  account: "アカウント",
  profile: "プロフィール",
  confirm: "確認",
};

export type Account = { email: string; password: string };
export type Profile = { name: string; age: number };

type FormStore = {
  step: Step;
  account: Account | null;
  profile: Profile | null;
  /** 真偽値を並べない。ありえない組み合わせを作れなくする */
  status: "editing" | "sending" | "done" | "error";
  message: string;

  /* 更新は「何が起きたか」で名前を付ける。setStep のような setter を並べない */
  submitAccount: (values: Account) => void;
  submitProfile: (values: Profile) => void;
  goBack: () => void;
  send: () => Promise<void>;
  restart: () => void;
};

const initial = {
  step: "account" as Step,
  account: null,
  profile: null,
  status: "editing" as const,
  message: "",
};

export const useFormStore = create<FormStore>((set, get) => ({
  ...initial,

  // 「アカウントが入力された」なら、保存して次へ進むまでが 1 セット。
  // 呼ぶ側が 2 つの操作を組み合わせる必要がない
  submitAccount: (values) => set({ account: values, step: "profile" }),

  submitProfile: (values) => set({ profile: values, step: "confirm" }),

  goBack: () =>
    set((state) => ({
      step: state.step === "confirm" ? "profile" : "account",
    })),

  // 通信もストアの中に置ける。画面側は send() を呼ぶだけ
  send: async () => {
    set({ status: "sending", message: "" });

    try {
      const response = await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: get().profile?.name ?? "" }),
      });

      if (!response.ok) {
        const data = await response.json().catch(() => null);
        throw new Error(data?.message ?? "送信に失敗しました");
      }

      set({ status: "done" });
    } catch (error) {
      set({
        status: "error",
        message: error instanceof Error ? error.message : "送信に失敗しました",
      });
    }
  },

  restart: () => set(initial),
}));

入力そのものは、ライブラリに任せる

state の設計はできました。 では入力欄 1 つ 1 つはどうするか。 ここは Part 10 の React Hook Form に任せます。

const accountSchema = z.object({
  email: z.email("メールアドレスの形式が正しくありません"),
  password: z.string().min(8, "8 文字以上で入力してください"),
});

画面ごとに決まりを分けて書けるのが利点です。1 画面目を通らなければ 2 画面目に進めないので、 検査も画面ごとで足ります。

そして React Hook Form は打つたびに描き直しません。 さきほどの版で全部光っていたのが、ここで効いてきます。

multi-step-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { stepLabels, steps, useFormStore, type Step } from "./form-store";

const accountSchema = z.object({
  email: z.email("メールアドレスの形式が正しくありません"),
  password: z.string().min(8, "8 文字以上で入力してください"),
});

const profileSchema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

type AccountValues = z.infer<typeof accountSchema>;
type ProfileValues = z.infer<typeof profileSchema>;

function StepAccount({
  onNext,
}: {
  onNext: (values: AccountValues) => void;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<AccountValues>({
    resolver: zodResolver(accountSchema),
    mode: "onTouched",
  });

  return (
    <RenderBox title="1. アカウント" tone="highlight">
      <form
        onSubmit={handleSubmit(onNext)}
        className="flex flex-col gap-2"
        noValidate
      >
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          {...register("email")}
        />
        {errors.email && (
          <p role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}

        <Input
          type="password"
          placeholder="パスワード(8 文字以上)"
          aria-label="パスワード"
          {...register("password")}
        />
        {errors.password && (
          <p role="alert" className="text-sm text-destructive">
            {errors.password.message}
          </p>
        )}

        <Button size="sm" type="submit">
          次へ
        </Button>
      </form>
    </RenderBox>
  );
}

function StepProfile({
  onNext,
  onBack,
}: {
  onNext: (values: ProfileValues) => void;
  onBack: () => void;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ProfileValues>({
    resolver: zodResolver(profileSchema),
    mode: "onTouched",
  });

  return (
    <RenderBox title="2. プロフィール" tone="highlight">
      <form
        onSubmit={handleSubmit(onNext)}
        className="flex flex-col gap-2"
        noValidate
      >
        <Input placeholder="名前" aria-label="名前" {...register("name")} />
        {errors.name && (
          <p role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}

        <Input
          placeholder="年齢"
          aria-label="年齢"
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}

        <div className="flex gap-2">
          <Button size="sm" type="button" variant="outline" onClick={onBack}>
            戻る
          </Button>
          <Button size="sm" type="submit">
            次へ
          </Button>
        </div>
      </form>
    </RenderBox>
  );
}

export function MultiStepForm() {
  useTrackDemoRender();

  // 必要なものだけ取り出す
  const step = useFormStore((state) => state.step);
  const account = useFormStore((state) => state.account);
  const profile = useFormStore((state) => state.profile);
  const status = useFormStore((state) => state.status);
  const message = useFormStore((state) => state.message);

  const submitAccount = useFormStore((state) => state.submitAccount);
  const submitProfile = useFormStore((state) => state.submitProfile);
  const goBack = useFormStore((state) => state.goBack);
  const send = useFormStore((state) => state.send);
  const restart = useFormStore((state) => state.restart);

  /*
    ストアはコンポーネントの外にあるので、画面を離れても中身が残る。
    実際のアプリではそれが利点だが、この章は何度でも試せるほうがよいので、
    デモから離れるときに初期化しておく。
  */
  useEffect(() => restart, [restart]);

  return (
    <div className="flex flex-col gap-4">
      {/* いまどこにいるか */}
      <ol className="flex flex-wrap gap-2 text-sm">
        {steps.map((name: Step) => (
          <li
            key={name}
            className={`rounded-md border px-3 py-1 ${
              name === step
                ? "border-foreground/40 font-semibold"
                : "text-muted-foreground"
            }`}
          >
            {stepLabels[name]}
          </li>
        ))}
      </ol>

      {step === "account" && (
        <StepAccount
          onNext={(values) => submitAccount(values)}
        />
      )}

      {step === "profile" && (
        <StepProfile
          onNext={(values) => submitProfile(values)}
          onBack={() => goBack()}
        />
      )}

      {step === "confirm" && (
        <RenderBox title="3. 確認" tone="highlight">
          <div className="flex flex-col gap-3">
            <dl className="grid grid-cols-[6rem_1fr] gap-1 text-sm">
              <dt className="text-muted-foreground">メール</dt>
              <dd>{account?.email}</dd>
              <dt className="text-muted-foreground">名前</dt>
              <dd>{profile?.name}</dd>
              <dt className="text-muted-foreground">年齢</dt>
              <dd>{profile?.age}</dd>
            </dl>

            {status === "error" && (
              <p role="alert" className="text-sm text-destructive">
                {message}
              </p>
            )}

            {status === "done" ? (
              <p className="text-sm text-emerald-700 dark:text-emerald-400">
                送信しました
              </p>
            ) : (
              <div className="flex gap-2">
                <Button
                  size="sm"
                  type="button"
                  variant="outline"
                  onClick={() => goBack()}
                  disabled={status === "sending"}
                >
                  戻る
                </Button>
                <Button
                  size="sm"
                  type="button"
                  onClick={send}
                  disabled={status === "sending"}
                >
                  {status === "sending" ? "送信中…" : "送信する"}
                </Button>
              </div>
            )}
          </div>
        </RenderBox>
      )}

      <Button
        size="sm"
        variant="ghost"
        onClick={() => restart()}
      >
        最初からやり直す
      </Button>
    </div>
  );
}

組み合わせた版

ストアと React Hook Form を組み合わせた版直した例

3 画面を進んで、送信まで試す

  1. アカウント
  2. プロフィール
  3. 確認
1. アカウント

試すことは 4 つです。

  • 打ってみる 打っている間、箱は光りません。 カードの render も増えません
  • 空のまま「次へ」 エラーが出て、先に進みません
  • 戻る 1 つ前の画面に戻ります
  • 送信する 送信中はボタンが押せなくなります(Part 9 の二重送信の話)

描き直しの範囲が、はっきり分かれました。打っている間は誰も描き直されません。 描き直されるのは画面が切り替わったときだけです。 変わるべきときにだけ変わる、という状態です。

multi-step-form.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { stepLabels, steps, useFormStore, type Step } from "./form-store";

const accountSchema = z.object({
  email: z.email("メールアドレスの形式が正しくありません"),
  password: z.string().min(8, "8 文字以上で入力してください"),
});

const profileSchema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  age: z
    .number({ error: "数字を入力してください" })
    .min(18, "18 歳以上で入力してください"),
});

type AccountValues = z.infer<typeof accountSchema>;
type ProfileValues = z.infer<typeof profileSchema>;

function StepAccount({
  onNext,
}: {
  onNext: (values: AccountValues) => void;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<AccountValues>({
    resolver: zodResolver(accountSchema),
    mode: "onTouched",
  });

  return (
    <RenderBox title="1. アカウント" tone="highlight">
      <form
        onSubmit={handleSubmit(onNext)}
        className="flex flex-col gap-2"
        noValidate
      >
        <Input
          placeholder="メールアドレス"
          aria-label="メールアドレス"
          {...register("email")}
        />
        {errors.email && (
          <p role="alert" className="text-sm text-destructive">
            {errors.email.message}
          </p>
        )}

        <Input
          type="password"
          placeholder="パスワード(8 文字以上)"
          aria-label="パスワード"
          {...register("password")}
        />
        {errors.password && (
          <p role="alert" className="text-sm text-destructive">
            {errors.password.message}
          </p>
        )}

        <Button size="sm" type="submit">
          次へ
        </Button>
      </form>
    </RenderBox>
  );
}

function StepProfile({
  onNext,
  onBack,
}: {
  onNext: (values: ProfileValues) => void;
  onBack: () => void;
}) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ProfileValues>({
    resolver: zodResolver(profileSchema),
    mode: "onTouched",
  });

  return (
    <RenderBox title="2. プロフィール" tone="highlight">
      <form
        onSubmit={handleSubmit(onNext)}
        className="flex flex-col gap-2"
        noValidate
      >
        <Input placeholder="名前" aria-label="名前" {...register("name")} />
        {errors.name && (
          <p role="alert" className="text-sm text-destructive">
            {errors.name.message}
          </p>
        )}

        <Input
          placeholder="年齢"
          aria-label="年齢"
          {...register("age", { valueAsNumber: true })}
        />
        {errors.age && (
          <p role="alert" className="text-sm text-destructive">
            {errors.age.message}
          </p>
        )}

        <div className="flex gap-2">
          <Button size="sm" type="button" variant="outline" onClick={onBack}>
            戻る
          </Button>
          <Button size="sm" type="submit">
            次へ
          </Button>
        </div>
      </form>
    </RenderBox>
  );
}

export function MultiStepForm() {
  useTrackDemoRender();

  // 必要なものだけ取り出す
  const step = useFormStore((state) => state.step);
  const account = useFormStore((state) => state.account);
  const profile = useFormStore((state) => state.profile);
  const status = useFormStore((state) => state.status);
  const message = useFormStore((state) => state.message);

  const submitAccount = useFormStore((state) => state.submitAccount);
  const submitProfile = useFormStore((state) => state.submitProfile);
  const goBack = useFormStore((state) => state.goBack);
  const send = useFormStore((state) => state.send);
  const restart = useFormStore((state) => state.restart);

  /*
    ストアはコンポーネントの外にあるので、画面を離れても中身が残る。
    実際のアプリではそれが利点だが、この章は何度でも試せるほうがよいので、
    デモから離れるときに初期化しておく。
  */
  useEffect(() => restart, [restart]);

  return (
    <div className="flex flex-col gap-4">
      {/* いまどこにいるか */}
      <ol className="flex flex-wrap gap-2 text-sm">
        {steps.map((name: Step) => (
          <li
            key={name}
            className={`rounded-md border px-3 py-1 ${
              name === step
                ? "border-foreground/40 font-semibold"
                : "text-muted-foreground"
            }`}
          >
            {stepLabels[name]}
          </li>
        ))}
      </ol>

      {step === "account" && (
        <StepAccount
          onNext={(values) => submitAccount(values)}
        />
      )}

      {step === "profile" && (
        <StepProfile
          onNext={(values) => submitProfile(values)}
          onBack={() => goBack()}
        />
      )}

      {step === "confirm" && (
        <RenderBox title="3. 確認" tone="highlight">
          <div className="flex flex-col gap-3">
            <dl className="grid grid-cols-[6rem_1fr] gap-1 text-sm">
              <dt className="text-muted-foreground">メール</dt>
              <dd>{account?.email}</dd>
              <dt className="text-muted-foreground">名前</dt>
              <dd>{profile?.name}</dd>
              <dt className="text-muted-foreground">年齢</dt>
              <dd>{profile?.age}</dd>
            </dl>

            {status === "error" && (
              <p role="alert" className="text-sm text-destructive">
                {message}
              </p>
            )}

            {status === "done" ? (
              <p className="text-sm text-emerald-700 dark:text-emerald-400">
                送信しました
              </p>
            ) : (
              <div className="flex gap-2">
                <Button
                  size="sm"
                  type="button"
                  variant="outline"
                  onClick={() => goBack()}
                  disabled={status === "sending"}
                >
                  戻る
                </Button>
                <Button
                  size="sm"
                  type="button"
                  onClick={send}
                  disabled={status === "sending"}
                >
                  {status === "sending" ? "送信中…" : "送信する"}
                </Button>
              </div>
            )}
          </div>
        </RenderBox>
      )}

      <Button
        size="sm"
        variant="ghost"
        onClick={() => restart()}
      >
        最初からやり直す
      </Button>
    </div>
  );
}

送信中かどうかも、同じ入れ物に入れる

type FormStore = {
  step: Step;
  account: {...} | null;
  profile: {...} | null;
  status: "editing" | "sending" | "done" | "error";
  message: string;
};

isSendingisDone hasError を並べていないところを見てください。 Part 4「useState の使い方いろいろ」でやった真偽値を並べない形です。

真偽値を 3 つ持つと、「送信中なのに完了もしている」という ありえない組み合わせが書けてしまいます。 1 つの status にまとめれば、ありえない状態が存在できません

form-store.ts

import { create } from "zustand";

/** 申込の 3 段階。文字列を直接書かず、この 3 つに絞る */
export type Step = "account" | "profile" | "confirm";

export const steps: Step[] = ["account", "profile", "confirm"];

export const stepLabels: Record<Step, string> = {
  account: "アカウント",
  profile: "プロフィール",
  confirm: "確認",
};

export type Account = { email: string; password: string };
export type Profile = { name: string; age: number };

type FormStore = {
  step: Step;
  account: Account | null;
  profile: Profile | null;
  /** 真偽値を並べない。ありえない組み合わせを作れなくする */
  status: "editing" | "sending" | "done" | "error";
  message: string;

  /* 更新は「何が起きたか」で名前を付ける。setStep のような setter を並べない */
  submitAccount: (values: Account) => void;
  submitProfile: (values: Profile) => void;
  goBack: () => void;
  send: () => Promise<void>;
  restart: () => void;
};

const initial = {
  step: "account" as Step,
  account: null,
  profile: null,
  status: "editing" as const,
  message: "",
};

export const useFormStore = create<FormStore>((set, get) => ({
  ...initial,

  // 「アカウントが入力された」なら、保存して次へ進むまでが 1 セット。
  // 呼ぶ側が 2 つの操作を組み合わせる必要がない
  submitAccount: (values) => set({ account: values, step: "profile" }),

  submitProfile: (values) => set({ profile: values, step: "confirm" }),

  goBack: () =>
    set((state) => ({
      step: state.step === "confirm" ? "profile" : "account",
    })),

  // 通信もストアの中に置ける。画面側は send() を呼ぶだけ
  send: async () => {
    set({ status: "sending", message: "" });

    try {
      const response = await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: get().profile?.name ?? "" }),
      });

      if (!response.ok) {
        const data = await response.json().catch(() => null);
        throw new Error(data?.message ?? "送信に失敗しました");
      }

      set({ status: "done" });
    } catch (error) {
      set({
        status: "error",
        message: error instanceof Error ? error.message : "送信に失敗しました",
      });
    }
  },

  restart: () => set(initial),
}));

理解できたか確かめる

確認クイズ

ストアの更新関数を setStep や setAccount ではなく submitAccount という名前にするのはなぜ?

確認クイズ

React Hook Form を使うと、打っている間にカードが光らなくなるのはなぜ?

確認クイズ

status を 1 つの文字列にして、真偽値を 3 つ並べないのはなぜ?

form-store.ts

import { create } from "zustand";

/** 申込の 3 段階。文字列を直接書かず、この 3 つに絞る */
export type Step = "account" | "profile" | "confirm";

export const steps: Step[] = ["account", "profile", "confirm"];

export const stepLabels: Record<Step, string> = {
  account: "アカウント",
  profile: "プロフィール",
  confirm: "確認",
};

export type Account = { email: string; password: string };
export type Profile = { name: string; age: number };

type FormStore = {
  step: Step;
  account: Account | null;
  profile: Profile | null;
  /** 真偽値を並べない。ありえない組み合わせを作れなくする */
  status: "editing" | "sending" | "done" | "error";
  message: string;

  /* 更新は「何が起きたか」で名前を付ける。setStep のような setter を並べない */
  submitAccount: (values: Account) => void;
  submitProfile: (values: Profile) => void;
  goBack: () => void;
  send: () => Promise<void>;
  restart: () => void;
};

const initial = {
  step: "account" as Step,
  account: null,
  profile: null,
  status: "editing" as const,
  message: "",
};

export const useFormStore = create<FormStore>((set, get) => ({
  ...initial,

  // 「アカウントが入力された」なら、保存して次へ進むまでが 1 セット。
  // 呼ぶ側が 2 つの操作を組み合わせる必要がない
  submitAccount: (values) => set({ account: values, step: "profile" }),

  submitProfile: (values) => set({ profile: values, step: "confirm" }),

  goBack: () =>
    set((state) => ({
      step: state.step === "confirm" ? "profile" : "account",
    })),

  // 通信もストアの中に置ける。画面側は send() を呼ぶだけ
  send: async () => {
    set({ status: "sending", message: "" });

    try {
      const response = await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: get().profile?.name ?? "" }),
      });

      if (!response.ok) {
        const data = await response.json().catch(() => null);
        throw new Error(data?.message ?? "送信に失敗しました");
      }

      set({ status: "done" });
    } catch (error) {
      set({
        status: "error",
        message: error instanceof Error ? error.message : "送信に失敗しました",
      });
    }
  },

  restart: () => set(initial),
}));

この章のまとめ

  • 項目が増えたら、useState を並べずにひとまとめにする
  • 更新は「何が起きたか」で伝える。 決まりはストアの中に 1 か所だけ置く
  • 通信もストアに置ける。画面側は呼ぶだけ
  • 入力欄そのものはライブラリに任せる。 打つたびの描き直しがなくなる
  • 送信中・完了・失敗は1 つの status に。 真偽値を並べない

form-store.ts

import { create } from "zustand";

/** 申込の 3 段階。文字列を直接書かず、この 3 つに絞る */
export type Step = "account" | "profile" | "confirm";

export const steps: Step[] = ["account", "profile", "confirm"];

export const stepLabels: Record<Step, string> = {
  account: "アカウント",
  profile: "プロフィール",
  confirm: "確認",
};

export type Account = { email: string; password: string };
export type Profile = { name: string; age: number };

type FormStore = {
  step: Step;
  account: Account | null;
  profile: Profile | null;
  /** 真偽値を並べない。ありえない組み合わせを作れなくする */
  status: "editing" | "sending" | "done" | "error";
  message: string;

  /* 更新は「何が起きたか」で名前を付ける。setStep のような setter を並べない */
  submitAccount: (values: Account) => void;
  submitProfile: (values: Profile) => void;
  goBack: () => void;
  send: () => Promise<void>;
  restart: () => void;
};

const initial = {
  step: "account" as Step,
  account: null,
  profile: null,
  status: "editing" as const,
  message: "",
};

export const useFormStore = create<FormStore>((set, get) => ({
  ...initial,

  // 「アカウントが入力された」なら、保存して次へ進むまでが 1 セット。
  // 呼ぶ側が 2 つの操作を組み合わせる必要がない
  submitAccount: (values) => set({ account: values, step: "profile" }),

  submitProfile: (values) => set({ profile: values, step: "confirm" }),

  goBack: () =>
    set((state) => ({
      step: state.step === "confirm" ? "profile" : "account",
    })),

  // 通信もストアの中に置ける。画面側は send() を呼ぶだけ
  send: async () => {
    set({ status: "sending", message: "" });

    try {
      const response = await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: get().profile?.name ?? "" }),
      });

      if (!response.ok) {
        const data = await response.json().catch(() => null);
        throw new Error(data?.message ?? "送信に失敗しました");
      }

      set({ status: "done" });
    } catch (error) {
      set({
        status: "error",
        message: error instanceof Error ? error.message : "送信に失敗しました",
      });
    }
  },

  restart: () => set(initial),
}));