Tier 2 · CommonRecipe #7

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.

create-item-modal.tsx
'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.

parent-component.tsx
'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.

create-item-dialog.tsx
'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 open

When 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 close

If 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 field

Always 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: none or visibility: hidden to 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 explicit useEffect reset.

  • Using isSubmitSuccessful to close the modal

    isSubmitSuccessful does not give you access to the action result. Prefer onSuccess, which receives the full typed result and is a cleaner place to trigger onClose().

Related