React Form Hooks — useFormState, useFormStatus, and useActionState

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Forms are everywhere in web apps — login, signup, contact, settings. Before React 19, managing loading states, validation errors, and success messages required multiple useState calls and careful coordination. The new form hooks integrate directly with React Server Actions, making forms simpler and more reliable.

useFormStatus — Track Submission State

useFormStatus reads the pending state of the nearest parent <form>. It must be called inside a child component of the form element.

'use client'
 
import { useFormStatus } from 'react-dom'
 
function SubmitButton({ label = 'Submit' }: { label?: string }) {
  const { pending, data, method, action } = useFormStatus()
 
  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {pending ? (
        <span className="flex items-center gap-2">
          <span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
          Saving...
        </span>
      ) : label}
    </button>
  )
}
 
export function ContactForm() {
  return (
    <form action={submitContactForm} className="space-y-4 max-w-md">
      <input name="email" type="email" required className="w-full border rounded px-3 py-2" placeholder="Email" />
      <textarea name="message" required className="w-full border rounded px-3 py-2 h-32" placeholder="Message" />
      <SubmitButton label="Send Message" />
    </form>
  )
}

useActionState — Form State with Server Actions

useActionState (the React 19 replacement for useFormState) links a form to a Server Action and exposes the returned state.

// app/actions.ts
'use server'
 
type FormState = {
  error?: string
  success?: boolean
  fieldErrors?: Record<string, string>
}
 
export async function submitContactForm(
  previousState: FormState | null,
  formData: FormData
): Promise<FormState> {
  const email = formData.get('email') as string
  const message = formData.get('message') as string
 
  if (!email || !email.includes('@')) {
    return { fieldErrors: { email: 'Please enter a valid email' } }
  }
 
  if (!message || message.length &lt; 10) {
    return { fieldErrors: { message: 'Message must be at least 10 characters' } }
  }
 
  try {
    await sendEmail({ to: email, body: message })
    return { success: true }
  } catch {
    return { error: 'Failed to send. Please try again.' }
  }
}
'use client'
 
import { useActionState } from 'react'
import { submitContactForm } from '@/app/actions'
 
export function ContactForm() {
  const [state, formAction] = useActionState(submitContactForm, null)
 
  if (state?.success) {
    return (
      <div className="p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
        Message sent! We will get back to you within 24 hours.
      </div>
    )
  }
 
  return (
    <form action={formAction} className="space-y-4 max-w-md">
      {state?.error && (
        <div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
          {state.error}
        </div>
      )}
 
      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          className="w-full border rounded px-3 py-2"
          aria-describedby={state?.fieldErrors?.email ? 'email-error' : undefined}
        />
        {state?.fieldErrors?.email && (
          <p id="email-error" className="text-red-600 text-sm mt-1">{state.fieldErrors.email}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-1">Message</label>
        <textarea
          id="message"
          name="message"
          rows={5}
          className="w-full border rounded px-3 py-2"
        />
        {state?.fieldErrors?.message && (
          <p className="text-red-600 text-sm mt-1">{state.fieldErrors.message}</p>
        )}
      </div>
 
      <SubmitButton label="Send Message" />
    </form>
  )
}

Real-World: Authentication Form

// app/actions.ts
'use server'
 
import { redirect } from 'next/navigation'
 
export async function loginAction(prev: any, formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string
 
  const user = await verifyCredentials(email, password)
  if (!user) return { error: 'Invalid email or password' }
 
  await createSession(user.id)
  redirect('/dashboard')
}
'use client'
 
import { useActionState } from 'react'
import { loginAction } from '@/app/actions'
import { useFormStatus } from 'react-dom'
 
function LoginButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending} className="w-full bg-blue-600 text-white py-2 rounded">
      {pending ? 'Signing in...' : 'Sign In'}
    </button>
  )
}
 
export function LoginForm() {
  const [state, formAction] = useActionState(loginAction, null)
 
  return (
    <form action={formAction} className="space-y-4 max-w-sm mx-auto">
      <h1 className="text-2xl font-bold">Sign In</h1>
 
      {state?.error && (
        <p className="text-red-600 text-sm bg-red-50 p-3 rounded">{state.error}</p>
      )}
 
      <input name="email" type="email" placeholder="Email" className="w-full border rounded px-3 py-2" required />
      <input name="password" type="password" placeholder="Password" className="w-full border rounded px-3 py-2" required />
      <LoginButton />
    </form>
  )
}

Common Mistakes

  • Calling useFormStatus at the form level itself instead of inside a child component — it will always return pending: false
  • Returning non-serializable values from Server Actions — state must be JSON-serializable
  • Not providing initial state as the second argument to useActionState
  • Forgetting that useFormStatus requires the component to be a descendant of <form>, not a sibling

Best Practices

  • Extract SubmitButton as a separate component so useFormStatus works correctly
  • Return structured error objects with field-level keys so you can display inline validation messages
  • Reset form fields by rendering a key-based re-mount on success: <form key={state?.success ? 'success' : 'idle'} ...>
  • Use redirect() inside Server Actions for post-submission navigation instead of client-side router

Key Takeaways

  • useFormStatus tracks the pending state of the nearest parent form — always use it inside a child component, not the form itself
  • useActionState links a form to a Server Action and exposes the action's returned value as component state
  • Server Actions replace traditional API route fetch calls for form submissions, with less boilerplate
  • Return structured fieldErrors objects from Server Actions to enable per-field validation messages
  • Combine useFormStatus and useActionState in the same form for both a disabled button and error display
  • Server Actions automatically handle CSRF protection — no need to add tokens manually
  • The pending property from useFormStatus is true only while the form submission is in-flight
  • These hooks work without JavaScript enabled because they fall back to native HTML form behavior

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading