Tier 1 · Must-haveRecipe #4

Edit Form with Server-Loaded Data

Pre-populate a form from a Server Component, track which fields the user actually changed, and revalidate the page data after a successful save.

Why it matters

Edit forms are the second most common form type after login. They require loading data from the server and using it as defaultValues — but there's a fundamental tension: Server Components can fetch data asynchronously, while useActionForm lives in a Client Component. Getting the data handoff right prevents stale defaults and unintended dirty-field detection.

This recipe shows the canonical Server → Client pattern, how to use isDirty to show the save button only when something changed, and how to use router.refresh() to revalidate without a full page reload.

Full Example

actions.ts
'use server'
import { z } from 'zod'
import { withZod } from 'hookform-action-core/with-zod'

const profileSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  bio: z.string().max(160, 'Bio must be 160 characters or fewer'),
  website: z.string().url('Enter a valid URL').optional().or(z.literal('')),
})

export const updateProfileAction = withZod(profileSchema, async (data) => {
  // data is typed: { name: string; bio: string; website?: string }
  await db.profiles.update({
    where: { userId: session.userId },
    data,
  })
  return { success: true }
})
page.tsx — Server Component
// This is a Server Component — it can be async and access the database directly
import { db } from '@/lib/db'
import { EditProfileForm } from './edit-profile-form'

export default async function EditProfilePage() {
  // Fetch the current profile on the server
  const profile = await db.profiles.findUnique({
    where: { userId: session.userId },
    select: { name: true, bio: true, website: true },
  })

  if (!profile) return <p>Profile not found.</p>

  // Pass to the Client Component as a plain prop
  return <EditProfileForm defaultValues={profile} />
}
edit-profile-form.tsx — Client Component
'use client'
import { useActionForm } from 'hookform-action'
import { useRouter } from 'next/navigation'
import { updateProfileAction } from './actions'

interface EditProfileFormProps {
  defaultValues: {
    name: string
    bio: string
    website: string
  }
}

export function EditProfileForm({ defaultValues }: EditProfileFormProps) {
  const router = useRouter()
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isPending, isDirty, dirtyFields },
  } = useActionForm(updateProfileAction, {
    defaultValues,
    onSuccess: (result) => {
      // Re-fetch the Server Component data without a full reload
      router.refresh()
      // Optionally reset to the newly saved values to clear isDirty
      reset(defaultValues)
    },
  })

  return (
    <form onSubmit={handleSubmit()}>
      <div>
        <label htmlFor="name">Display Name</label>
        <input id="name" {...register('name')} />
        {errors.name && <p className="text-red-400">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="bio">
          Bio
          {dirtyFields.bio && (
            <span className="text-xs text-brand-400 ml-2">modified</span>
          )}
        </label>
        <textarea id="bio" {...register('bio')} rows={3} />
        {errors.bio && <p className="text-red-400">{errors.bio.message}</p>}
      </div>

      <div>
        <label htmlFor="website">Website</label>
        <input id="website" type="url" {...register('website')} />
        {errors.website && <p className="text-red-400">{errors.website.message}</p>}
      </div>

      {/* Only show the Save button when something has changed */}
      <button
        type="submit"
        disabled={isPending || !isDirty}
        className={!isDirty ? 'opacity-50 cursor-not-allowed' : ''}
      >
        {isPending ? 'Saving…' : isDirty ? 'Save Changes' : 'No Changes'}
      </button>
    </form>
  )
}

Key Concepts

Server Component → Client Component data handoff

Fetch data in an async Server Component, then pass it as a plain prop to the Client Component. The Client Component receives it as a stable object and uses it as defaultValues. This avoids the useEffect fetch anti-pattern and leverages Next.js caching.

isDirty / dirtyFields

isDirty is true when at least one field differs from its defaultValue. dirtyFields is a record of which specific fields changed. Use them to disable the save button when nothing has been modified and to show per-field change indicators.

router.refresh()

Re-executes all Server Components on the current route without a full navigation. The Server Component re-fetches the updated data and passes it as new props to the Client Component. Call it in onSuccess after a successful save.

reset(newValues) after save

After a successful edit, call reset(result.data) or reset(defaultValues) to update the RHF baseline. This clears isDirty so the save button correctly disables again without a full remount.

⚠️ Pitfalls

  • Unstable defaultValues causing unintended resets

    If defaultValues is an inline object literal created on every render, RHF may re-initialise and clear user edits. Always pass a stable reference — either from a Server Component prop, a useMemo, or a fetched query result.

  • isSubmitSuccessful stays true after save

    For edit forms you usually want the user to stay on the page and keep editing. Avoid gating the form behind isSubmitSuccessful — it will hide the form after the first save. Use onSuccess for side effects only.

  • Forgetting reset() after a successful save

    Without reset(), isDirty stays true even though the changes were saved. The save button remains enabled and the user may accidentally re-submit.

Related