Edit profile

Edit a form that starts filled with the current values, then save it. Includes picking an image, a character counter, and a notification after saving.

Edit Profile

Key points

  1. 1

    Fill in the current values from the start

    Pass the current profile to defaultValues. On a real site, you’d use values fetched from the server.

  2. 2

    No changes, no saving

    Check isDirty (whether anything changed from the starting values) and disable the save button. After saving, call reset to make the saved values the new starting values.

  3. 3

    Confirm the save with a notification

    Show “Saved” in the bottom-right corner (a sonner toast). When the screen doesn’t change, people can’t tell whether it saved without a notification.

Tools used

Click to see the comparison in Part 2

Edit the code

Edit the code and the preview on the right updates.

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { toast } from "sonner"
import { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"
import { Toaster } from "./components/ui/sonner"

// ① Validation rules (Zod)
const schema = z.object({
  name: z.string().min(1, "Enter your name"),
  username: z.string().regex(/^[a-z0-9_]+$/, "Use only lowercase letters, numbers, and _"),
  bio: z.string().max(160, "Must be 160 characters or fewer"),
})
type Profile = z.infer<typeof schema>

// The current profile (in a real app, this comes from a server)
const current: Profile = {
  name: "John Smith",
  username: "john",
  bio: "Learning React.",
}

// Stand-in for a real server: wait a moment
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

// Group a label, an input, and its error message together
function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor={id}>{label}</Label>
      {children}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  )
}

export default function App() {
  // The picked image (null until one is picked)
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null)

  // ② defaultValues: start with the current values filled in
  // isDirty: true once any value differs from the initial values (saving is disabled until then)
  const { register, handleSubmit, watch, reset, formState: { errors, isDirty, isSubmitting } } = useForm<Profile>({
    resolver: zodResolver(schema),
    defaultValues: current,
  })

  // The current input values (update on every keystroke)
  const name = watch("name")
  const bio = watch("bio")

  async function onSubmit(values: Profile) {
    await wait(800)
    // Make the saved values the new initial values (isDirty goes back to false)
    reset(values)
    // ③ Show a toast in the bottom-right when saved (sonner)
    toast.success("Saved")
  }

  // Show the picked image as the avatar (preview in the browser only; nothing is uploaded)
  function onPickImage(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (file) setAvatarUrl(URL.createObjectURL(file))
  }

  return (
    <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6">
      <p className="text-lg font-semibold">Profile</p>
      <p className="mt-1 text-sm text-muted-foreground">This is visible to other people</p>

      <form onSubmit={handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
        <div className="flex items-center gap-4">
          <Avatar className="size-16">
            {avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
            <AvatarFallback className="bg-linear-to-br from-sky-400 to-indigo-500 text-xl font-semibold text-white">{name.slice(0, 1)}</AvatarFallback>
          </Avatar>
          <div>
            {/* Make the hidden file input clickable through a label styled as a button */}
            <Button type="button" variant="outline" size="sm" asChild>
              <label htmlFor="avatar" className="cursor-pointer">Change photo</label>
            </Button>
            <input id="avatar" type="file" accept="image/*" className="hidden" onChange={onPickImage} />
            <p className="mt-1 text-xs text-muted-foreground">JPG or PNG, up to 2MB</p>
          </div>
        </div>

        <Field id="name" label="Name" error={errors.name?.message}>
          <Input id="name" {...register("name")} />
        </Field>
        <Field id="username" label="Username" error={errors.username?.message}>
          {/* An input with "@" on the left */}
          <div className="flex rounded-md border">
            <span className="flex items-center border-r bg-muted px-3 text-sm text-muted-foreground">@</span>
            <Input id="username" className="rounded-l-none border-0 shadow-none" {...register("username")} />
          </div>
        </Field>
        <Field id="bio" label="Bio" error={errors.bio?.message}>
          <textarea
            id="bio"
            rows={3}
            className="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
            {...register("bio")}
          />
          <p className="text-right text-xs text-muted-foreground">{bio.length} / 160</p>
        </Field>

        <div className="flex justify-end gap-2">
          <Button type="button" variant="outline" disabled={!isDirty} onClick={() => reset()}>
            Reset
          </Button>
          <Button type="submit" disabled={!isDirty || isSubmitting}>
            {isSubmitting ? "Saving..." : "Save"}
          </Button>          
        </div>
      </form>

      <Toaster position="bottom-right" />
    </div>
  )
}