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
'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 }
})// 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} />
}'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 handoffFetch 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 / dirtyFieldsisDirty 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 saveAfter 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
defaultValuescausing unintended resetsIf
defaultValuesis 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, auseMemo, or a fetched query result. - ⚠
isSubmitSuccessfulstaystrueafter saveFor 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. UseonSuccessfor side effects only. - ⚠
Forgetting
reset()after a successful saveWithout
reset(),isDirtystaystrueeven though the changes were saved. The save button remains enabled and the user may accidentally re-submit.