編輯表單
管理後台的「編輯商品」畫面。從伺服器讀取目前的內容填入表單,並儲存修改的部分。
編輯商品
重點
- 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" // 保存已取得資料的容器(整個 App 一個) const queryClient = new QueryClient() // 數字欄位:輸入欄位傳來的是文字,先轉成數字再驗證(空白時提示「請輸入…」) const numberField = (label) => z.preprocess( (value) => (value === "" ? undefined : Number(value)), z.number({ error: (issue) => (issue.input === undefined ? "請輸入" + label : "請輸入數字") }).int("請輸入整數"), ) // 把數字欄位輸入的文字轉成數字再放進表單(空白時維持原樣) // 不這樣做的話,就算重新輸入原本的 2550,也會因為「"2550"(文字)和 2550(數字)不同」而被當成有變更 const toNumber = (value) => (value === "" ? "" : Number(value)) // 驗證規則(Zod) const schema = z.object({ name: z.string().min(1, "請輸入商品名稱"), price: numberField("價格").pipe(z.number().min(1, "價格至少為 NT$1")), stock: numberField("庫存").pipe(z.number().min(0, "不能小於 0")), description: z.string().max(200, "最多 200 個字元"), }) // 把標籤、輸入欄位和錯誤訊息組合在一起 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="價格(NT$)" 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 包住整個 App,裡面就能使用 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> ) }