Tier 1 · Must-haveRecipe #3

Reset After Success

Three canonical patterns for post-submission UX: redirect, in-place reset with a toast, and showing a success state. Choosing the wrong one leads to subtle bugs.

Why it matters

"The form doesn't clear after I submit" is one of the most common issues raised by developers new to the library. The reason is that isSubmitSuccessful stays true indefinitely, and reset() needs to be called explicitly. These are intentional RHF behaviours — but they need to be wired correctly.

There are three distinct patterns depending on your UX goal. Pick the right one and you avoid ghost states, stale data, and infinite success screens.

Pattern 1 — Redirect after submit

Best for create-and-navigate flows: create a post, place an order, complete onboarding. The server calls redirect() and the page navigates away — no client-side reset needed.

actions.ts
'use server'
import { redirect } from 'next/navigation'
import { withZod } from 'hookform-action-core/with-zod'
import { postSchema } from './schema'

export const createPostAction = withZod(postSchema, async (data) => {
  const post = await db.posts.create({ data })
  // redirect() throws internally — the return below is unreachable
  redirect(`/posts/${post.id}`)
})
Note: After redirect() the hook sets isSubmitSuccessful = true, but the page navigates away immediately so this state is never rendered. You do not need to handle it on the client.

Pattern 2 — In-place reset with onSuccess

Best for forms that stay on the same page after submission: comment boxes, subscription forms, quick-add widgets. Use onSuccess to call reset() and optionally show a toast.

comment-form.tsx
'use client'
import { useActionForm } from 'hookform-action'
import { addCommentAction } from './actions'

export function CommentForm() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isPending },
  } = useActionForm(addCommentAction, {
    defaultValues: { text: '' },
    onSuccess: () => {
      reset()             // Clears all fields back to defaultValues
      // toast.success('Comment posted!')  // optional side effect
    },
  })

  return (
    <form onSubmit={handleSubmit()}>
      <textarea {...register('text')} placeholder="Write a comment…" />
      {errors.text && <p className="text-red-400">{errors.text.message}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Posting…' : 'Post Comment'}
      </button>
    </form>
  )
}
Resetting to different values (e.g. after edit)
onSuccess: (result) => {
  // Pass new values to reset() — useful after an edit form
  // where the server returns the updated record
  reset({
    title: result.data.title,
    body: result.data.body,
  })
}

Pattern 3 — isSubmitSuccessful guard

Best for single-use forms where you want to replace the form with a success message: contact forms, RSVP forms, one-time redemptions.

contact-form.tsx
'use client'
import { useActionForm } from 'hookform-action'
import { sendMessageAction } from './actions'

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isPending, isSubmitSuccessful },
  } = useActionForm(sendMessageAction, {
    defaultValues: { name: '', email: '', message: '' },
  })

  // Replace the form entirely once submitted
  if (isSubmitSuccessful) {
    return (
      <div className="text-center py-12">
        <p className="text-2xl font-bold text-green-400">Message sent!</p>
        <p className="text-gray-400 mt-2">We'll get back to you within 24 hours.</p>
      </div>
    )
  }

  return (
    <form onSubmit={handleSubmit()}>
      <input {...register('name')} placeholder="Your name" />
      {errors.name && <p className="text-red-400">{errors.name.message}</p>}
      <input type="email" {...register('email')} placeholder="Email" />
      {errors.email && <p className="text-red-400">{errors.email.message}</p>}
      <textarea {...register('message')} placeholder="Your message" />
      {errors.message && <p className="text-red-400">{errors.message.message}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending…' : 'Send Message'}
      </button>
    </form>
  )
}

Bonus — Clearing persistence after wizard submit

When using persistKey, always call clearPersistedData() in onSuccess to remove the saved draft from sessionStorage. Otherwise the user will see stale data if they open the form again.

const { handleSubmit, clearPersistedData } = useActionForm(wizardAction, {
  defaultValues: { ... },
  persistKey: 'onboarding-wizard',
  onSuccess: () => {
    clearPersistedData()   // ← remove draft from sessionStorage
    router.push('/dashboard')
  },
})

Key Concepts

reset()

Resets all fields to defaultValues, clears all errors, and resets RHF state (isDirty, isSubmitSuccessful, etc.). reset(newValues) additionally updates the stored default values — useful after editing a record.

isSubmitSuccessful

Becomes true after a successful action call (no field errors returned) and stays true until reset() is called or the component unmounts. This is intentional — use it as the guard for your success UI.

onSuccess callback

Receives the full action result and fires only when the action succeeds (no field errors). The best place to call reset(), show a toast, or trigger a router navigation.

⚠️ Pitfalls

  • Calling reset() in a useEffect watching isSubmitSuccessful

    This creates a double-render cycle. Prefer onSuccess — it fires synchronously after the action resolves, before any re-render.

  • Resetting inside a modal — flash of empty form

    If you reset() and then close the modal in onSuccess, the form briefly shows empty before the modal animation completes. Prefer resetting in the onClose handler instead, or use a key prop to remount.

  • reset() without arguments on an edit form

    If defaultValues came from the server and the user just saved new data, calling reset() reverts to the old server data. Use reset(result.data) to update the baseline after a successful edit.

Related