Edit form

The “Edit product” screen of an admin panel. Load the current data from the server into a form, and save what you change.

Edit Product

Key points

  1. 1

    Show a placeholder until loading finishes

    For the one second before the data arrives, show gray placeholder shapes (a skeleton). Empty input fields would make people think there’s nothing there.

  2. 2

    Use the loaded data as the form’s starting values

    Pass the fetched product to useForm’s values. The fields fill in the moment it arrives, and “has anything changed (isDirty)” is compared against it.

  3. 3

    Make it clear that something changed

    After a change, show “You have unsaved changes”. While saving, disable the button and show “Saving…”, then confirm with a notification when it’s done.

Tools used

Click to see the comparison in Part 2

Edit the code

Edit the code and the preview on the right updates.

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"

// A store that remembers fetched data (one per app)
const queryClient = new QueryClient()

// Number field: inputs give text, so convert to a number before checking (if empty, "Please enter …")
const numberField = (label) =>
  z.preprocess(
    (value) => (value === "" ? undefined : Number(value)),
    z.number({ error: (issue) => (issue.input === undefined ? "Please enter the " + label.toLowerCase() : "Please enter a number") }).int("Please enter a whole number"),
  )

// Convert the text typed in a number field to a number before putting it in the form (leave it empty if empty)
// Otherwise, retyping the original 85 would count as a change, because "85" (text) and 85 (number) are different
const toNumber = (value) => (value === "" ? "" : Number(value))

// Validation rules (Zod)
const schema = z.object({
  name: z.string().min(1, "Please enter a product name"),
  price: numberField("Price").pipe(z.number().min(1, "Must be at least $1")),
  stock: numberField("Stock").pipe(z.number().min(0, "Must be 0 or more")),
  description: z.string().max(200, "Please use 200 characters or fewer"),
})

// Group a label, an input, and its error message together
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>
  )
}

// Gray placeholder shapes shown while loading (a skeleton)
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() {
  // Fetch the current product from the server (isPending is true while fetching)
  const { data, isPending } = useQuery({ queryKey: ["product"], queryFn: fetchProduct })

  // The form. Passing the fetched product to values fills the inputs when it arrives
  const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
    resolver: zodResolver(schema),
    values: data,
  })

  // Save (mutation.isPending is true while saving)
  const mutation = useMutation({
    mutationFn: saveProduct,
    onSuccess: (saved) => {
      queryClient.setQueryData(["product"], saved) // Replace the remembered product with the saved one too
      reset(saved) // Make the saved values the new starting values (isDirty goes back to false)
      toast.success("Saved")
    },
  })

  // ① While loading (isPending is true), return only <FormSkeleton /> first
  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">Edit product</h1>
        {/* ② Show the badge below only when something changed (isDirty is true) */}
        {isDirty && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-700">Unsaved changes</span>}
      </div>

      <Field id="name" label="Product name" error={errors.name?.message}>
        <Input id="name" {...register("name")} />
      </Field>
      <div className="grid grid-cols-2 gap-4">
        <Field id="price" label="Price ($)" error={errors.price?.message}>
          <Input id="price" inputMode="numeric" {...register("price", { setValueAs: toNumber })} />
        </Field>
        <Field id="stock" label="Stock" error={errors.stock?.message}>
          <Input id="stock" inputMode="numeric" {...register("stock", { setValueAs: toNumber })} />
        </Field>
      </div>
      <Field id="description" label="Description" 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 when nothing changed or while saving */}
        {/* ⑤ On click, go back to the starting values (reset in onClick) */}
        <Button type="button" variant="outline" disabled={!isDirty || mutation.isPending} onClick={() => reset()}>
          Discard changes
        </Button>
        {/* ③ Disabled when nothing changed or while saving */}
        <Button type="submit" disabled={!isDirty || mutation.isPending}>
          {/* ④ "Saving…" while saving (mutation.isPending is true), otherwise "Save" */}
          {mutation.isPending ? "Saving…" : "Save"}
        </Button>
      </div>
    </form >
  )
}

export default function App() {
  return (
    // Wrapping the whole app in QueryClientProvider lets you use useQuery inside it
    <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>
  )
}