Modal / Dialog Form
Forms inside modals have a different lifecycle — they must reset when opened, close on success, and handle focus correctly. This recipe covers the canonical pattern and Shadcn/ui Dialog integration.
Why it matters
A form inside a modal behaves differently from a page-level form. If the modal does not unmount when closed, the form keeps its state — including errors from the last submission. When the user opens it again, they see stale data. When they close it after a success, they may briefly see an empty form before the animation completes.
This recipe solves these issues with two patterns: the useEffect reset pattern (for modals that stay mounted) and the key remount pattern (the nuclear option that guarantees a fresh form). It also shows Shadcn/ui Dialog integration.
Pattern 1 — useEffect reset (stays mounted)
Best for modals that are conditionally rendered but not unmounted on close (e.g. animated with CSS opacity). Reset the form when the modal opens and close it on success.
'use client'
import { useEffect } from 'react'
import { useActionForm } from 'hookform-action'
import { createItemAction } from './actions'
interface CreateItemModalProps {
isOpen: boolean
onClose: () => void
}
export function CreateItemModal({ isOpen, onClose }: CreateItemModalProps) {
const {
register,
handleSubmit,
reset,
formState: { errors, isPending },
} = useActionForm(createItemAction, {
defaultValues: { title: '', description: '' },
onSuccess: () => {
// Step 1: close the modal
onClose()
// Step 2: reset AFTER close to avoid flash of empty form
// We use setTimeout(0) to let the close animation start first
setTimeout(() => reset(), 150)
},
})
// When the modal opens, clear any leftover state from the previous session
useEffect(() => {
if (isOpen) reset()
}, [isOpen, reset])
if (!isOpen) return null
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md">
<h2 id="modal-title" className="text-lg font-bold mb-4">
Create Item
</h2>
<form onSubmit={handleSubmit()}>
<div className="mb-4">
<label htmlFor="title" className="block text-sm text-gray-400 mb-1">
Title
</label>
<input
id="title"
autoFocus
{...register('title')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
{errors.title && (
<p className="text-red-400 text-sm mt-1">{errors.title.message}</p>
)}
</div>
<div className="mb-6">
<label htmlFor="description" className="block text-sm text-gray-400 mb-1">
Description
</label>
<textarea
id="description"
rows={3}
{...register('description')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white"
/>
{errors.description && (
<p className="text-red-400 text-sm mt-1">{errors.description.message}</p>
)}
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 hover:bg-brand-500 disabled:bg-gray-700 text-white rounded-lg transition-colors"
>
{isPending ? 'Creating…' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)
}Pattern 2 — key remount (unmount on close)
The simplest approach: unmount and remount the form component on every open by passing a key that changes. React destroys and recreates the component, giving you a guaranteed fresh state. Best for modals where no CSS animation is needed.
'use client'
import { useState } from 'react'
import { CreateItemModal } from './create-item-modal'
export function ItemList() {
const [isOpen, setIsOpen] = useState(false)
const [openCount, setOpenCount] = useState(0)
const handleOpen = () => {
setOpenCount((c) => c + 1) // increment key to force remount
setIsOpen(true)
}
return (
<>
<button onClick={handleOpen}>New Item</button>
{isOpen && (
// key changes on every open → CreateItemModal is remounted with fresh state
<CreateItemModal
key={openCount}
onClose={() => setIsOpen(false)}
/>
)}
</>
)
}Shadcn/ui Dialog integration
Shadcn's Dialog keeps content mounted by default. Use the useEffect reset pattern and wire onOpenChange to close the modal.
'use client'
import { useEffect } from 'react'
import { useActionForm } from 'hookform-action'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { createItemAction } from './actions'
interface CreateItemDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CreateItemDialog({ open, onOpenChange }: CreateItemDialogProps) {
const {
register,
handleSubmit,
reset,
formState: { errors, isPending },
} = useActionForm(createItemAction, {
defaultValues: { title: '', description: '' },
onSuccess: () => {
onOpenChange(false)
setTimeout(() => reset(), 150)
},
})
useEffect(() => {
if (open) reset()
}, [open, reset])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Item</DialogTitle>
</DialogHeader>
<form id="create-item-form" onSubmit={handleSubmit()}>
<div className="space-y-4 py-4">
<div>
<label htmlFor="title" className="text-sm font-medium">Title</label>
<input id="title" {...register('title')} className="w-full mt-1" />
{errors.title && <p className="text-red-400 text-sm">{errors.title.message}</p>}
</div>
<div>
<label htmlFor="description" className="text-sm font-medium">Description</label>
<textarea id="description" {...register('description')} className="w-full mt-1" />
{errors.description && (
<p className="text-red-400 text-sm">{errors.description.message}</p>
)}
</div>
</div>
</form>
<DialogFooter>
<button type="button" onClick={() => onOpenChange(false)}>Cancel</button>
<button type="submit" form="create-item-form" disabled={isPending}>
{isPending ? 'Creating…' : 'Create'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Key Concepts
reset() in useEffect on openWhen the modal opens, reset the form to clear any state from the previous session. This is essential for modals that stay mounted between opens (e.g. animated with CSS).
setTimeout before reset on closeIf you call reset() synchronously in onSuccess before closing the modal, the user briefly sees an empty form during the close animation. A small delay prevents the flash.
autoFocus on first fieldAlways add autoFocus to the first input in the modal for accessibility. Screen reader users and keyboard users expect focus to move to the dialog content when it opens.
⚠️ Pitfalls
- ⚠
Hiding the modal with CSS instead of unmounting
If you use
display: noneorvisibility: hiddento hide the modal, the form component stays mounted and retains all its state. The user will see stale errors and values on the next open. Always pair this approach with an explicituseEffectreset. - ⚠
Using
isSubmitSuccessfulto close the modalisSubmitSuccessfuldoes not give you access to the action result. PreferonSuccess, which receives the full typed result and is a cleaner place to triggeronClose().