編集フォーム
管理画面の「商品を編集」です。今の内容をサーバーから読み込んでフォームに入れ、変えたところを保存します。
商品を編集
ここがポイント
- 1
読み込みが終わるまでは、仮の形を出す
サーバーから届くまでの1秒は、灰色の仮の形(スケルトン)を出す。空っぽの入力欄を出すと、「何も入っていない」と勘違いされる
- 2
届いたデータを、フォームの最初の値にする
useForm の values に取ってきた商品を渡す。届いた瞬間に入力欄が埋まり、「最初の値から変わったか(isDirty)」もそこから比べられる
- 3
変えたことがわかるようにする
変えたら「未保存の変更があります」と出す。保存中はボタンを押せなくして「保存中…」と出し、終わったら通知で伝える
使った道具
押すと、第2部のくらべるページへ
コードを書き換えてみる
コードを書き換えると、右の見た目が変わります
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { toast } from "sonner" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" import { Skeleton } from "./components/ui/skeleton" import { Toaster } from "./components/ui/sonner" import { fetchProduct, saveProduct } from "./api" import { Form } from "lucide-react" // 取ってきたデータを覚えておく入れ物(アプリに1つ) const queryClient = new QueryClient() // 数字の欄:入力欄からは文字で入ってくるので、数字に直してから確かめる(空なら「入力してください」) const numberField = (label) => z.preprocess( (value) => (value === "" ? undefined : Number(value)), z.number({ error: (issue) => (issue.input === undefined ? label + "を入力してください" : "半角の数字で入力してください") }).int("整数で入力してください"), ) // 数字の欄に打った文字を、数字に直してフォームに入れる(空ならそのまま) // こうしないと、元の 12800 に打ち直しても「"12800"(文字)と 12800(数字)はちがう」となり、変えたことになってしまう const toNumber = (value) => (value === "" ? "" : Number(value)) // 入力チェックの決まり(Zod) const schema = z.object({ name: z.string().min(1, "商品名を入力してください"), price: numberField("価格").pipe(z.number().min(1, "1円以上にしてください")), stock: numberField("在庫数").pipe(z.number().min(0, "0以上にしてください")), description: z.string().max(200, "200文字以内で入力してください"), }) // ラベル・入力欄・エラーの文を、1つのまとまりにする function Field({ id, label, error, children }) { return ( <div className="flex flex-col gap-2"> <Label htmlFor={id}>{label}</Label> {children} {error && <p className="text-sm text-destructive">{error}</p>} </div> ) } // 読み込み中に出す、灰色の仮の形(スケルトン) function FormSkeleton() { return ( <div className="flex flex-col gap-5"> <Skeleton className="h-6 w-40" /> {[1, 2, 3].map((n) => ( <div key={n} className="flex flex-col gap-2"> <Skeleton className="h-4 w-16" /> <Skeleton className="h-9 w-full" /> </div> ))} <Skeleton className="h-24 w-full" /> </div> ) } function EditProduct() { // サーバーから、今の商品を取ってくる(取ってくる間は isPending が true) const { data, isPending } = useQuery({ queryKey: ["product"], queryFn: fetchProduct }) // フォーム。values に取ってきた商品を渡すと、届いたときに入力欄に入る const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({ resolver: zodResolver(schema), values: data, }) // 保存(保存している間は mutation.isPending が true) const mutation = useMutation({ mutationFn: saveProduct, onSuccess: (saved) => { queryClient.setQueryData(["product"], saved) // 覚えている商品も、保存したものに入れ替える reset(saved) // 保存したものを「最初の値」にする(isDirty が false に戻る) toast.success("保存しました") }, }) // ① ここに:読み込み中(isPending が true)なら、先に <FormSkeleton /> だけを返す if (isPending) { return <FormSkeleton /> } return ( <form onSubmit={handleSubmit((values) => mutation.mutate(values))} className="flex flex-col gap-5"> <div className="flex items-center gap-3"> <h1 className="text-lg font-semibold">商品を編集</h1> {/* ② ここに:何か変えたとき(isDirty が true)だけ、下の札を出す */} {isDirty && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-700">未保存の変更があります</span>} </div> <Field id="name" label="商品名" error={errors.name?.message}> <Input id="name" {...register("name")} /> </Field> <div className="grid grid-cols-2 gap-4"> <Field id="price" label="価格(円)" error={errors.price?.message}> <Input id="price" inputMode="numeric" {...register("price", { setValueAs: toNumber })} /> </Field> <Field id="stock" label="在庫数" error={errors.stock?.message}> <Input id="stock" inputMode="numeric" {...register("stock", { setValueAs: toNumber })} /> </Field> </div> <Field id="description" label="説明" error={errors.description?.message}> <textarea id="description" rows={3} className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" {...register("description")} /> </Field> <div className="flex justify-end gap-2 border-t pt-4"> {/* ③ ここに:何も変えていないか、保存中なら押せない(disabled) */} {/* ⑤ ここに:押したら、最初の値に戻す(onClick で reset) */} <Button type="button" variant="outline" disabled={!isDirty || mutation.isPending} onClick={() => reset()}> 変更を取り消す </Button> {/* ③ ここに:何も変えていないか、保存中なら押せない(disabled) */} <Button type="submit" disabled={!isDirty || mutation.isPending}> {/* ④ ここに:保存中(mutation.isPending が true)なら「保存中…」、それ以外は「保存する」 */} {mutation.isPending ? "保存中…" : "保存する"} </Button> </div> </form > ) } export default function App() { return ( // アプリ全体を QueryClientProvider で囲むと、中で useQuery が使える <QueryClientProvider client={queryClient}> <div className="mx-auto my-6 w-full max-w-xl rounded-xl border bg-card p-6 text-card-foreground"> <EditProduct /> </div> <Toaster position="bottom-right" /> </QueryClientProvider> ) }