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("");どこか 1 つに打って、3 つの箱を見る
どこか 1 つに打って、下の 3 つの箱を見てください
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 () => { ... },
}));setStep や setAccount のような 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>
);
}組み合わせた版
3 画面を進んで、送信まで試す
- アカウント
- プロフィール
- 確認
試すことは 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;
};isSending と isDone と 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),
}));