React 19 useFormStatus and useFormState: Build Accessible Forms Without Extra State Libraries
DEV Community

React 19 useFormStatus and useFormState: Build Accessible Forms Without Extra State Libraries

React 19 useFormStatus and useFormState: Build Accessible Forms Without Extra State Libraries

Disclaimer: This article was written with the assistance of AI, under human supervision and review.

Most form validation problems in React applications stem from treating client-side state and server-side validation as separate concerns. Teams reach for Formik, React Hook Form, or other libraries to manage loading indicators, error messages, and submission state-adding 30KB+ to their bundle before writing a single line of business logic.

React 19's useFormStatus and useFormState (also known as useActionState) eliminate this dependency by providing first-class primitives that track form lifecycle directly.

The cost of this library dependence shows up in three places: bundle size, runtime overhead, and accessibility gaps. Third-party form libraries manage their own state trees that re-render components independently of React's scheduler. When a form submits, the library tracks loading state separately from the server action that actually processes the data. This creates race conditions where a button shows "Submitting..." while the network request has already failed. Screen readers announce stale states because ARIA attributes update before the actual DOM reflects the error.

React 19 solves this by making forms a native React concern. The useFormStatus hook exposes the pending state of the nearest parent <form> element. The useFormState hook manages server action results and progressive enhancement. Both hooks integrate directly with React's transition system, ensuring that loading states, error messages, and validation feedback update atomically with the form's actual submission lifecycle.

Key Takeaways

  • useFormStatus provides real-time access to form submission state without managing separate loading flags
  • useFormState connects server actions to client-side validation, handling errors and success states in a single hook
  • Both hooks integrate with React's concurrent features to prevent race conditions between UI state and network requests
  • Proper ARIA attribute management through these hooks ensures screen readers announce form states accurately
  • Teams can eliminate form library dependencies while improving accessibility and reducing bundle size by 20-40KB

Understanding useFormStatus: Real-Time Form Submission State

useFormStatus exposes four properties that reflect the current state of a form submission: pending, data, method, and action. The hook must be called from a component rendered inside a <form> element-it accesses the submission context from the nearest parent form through React's internal fiber tree.

The pending property returns true when the form is submitting and false otherwise. This boolean drives loading spinners, disabled states, and optimistic UI updates. The data property contains the FormData object being submitted, allowing components to inspect field values during submission. The method property reflects the HTTP method (GET or POST), and action contains the function or URL handling the submission.

The critical distinction here is that useFormStatus only works in child components of the form, not in the form component itself. This prevents infinite render loops where the form's submission state triggers a re-render that resets the submission. When you need to disable a submit button during submission, extract that button into a separate component that calls useFormStatus.

The hook's integration with React's transition system means that state updates triggered by pending changes automatically become low-priority. If a user starts typing in another field while the form is submitting, React prioritizes the input update over the loading spinner animation. This distinction is critical for maintaining responsive UIs during slow network requests.

Building a Login Form with useFormStatus

A production login form needs four pieces of UI feedback during submission: a disabled submit button, a loading indicator, optimistic field locking, and screen reader announcements. useFormStatus handles all four without external state management.

'use client'
import { useFormStatus } from 'react'

async function authenticateUser(formData: FormData) {
  'use server'
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  // Simulate authentication delay
  await new Promise(resolve => setTimeout(resolve, 2000))

  if (email === 'test@example.com' && password === 'password') {
    return { success: true }
  }
  throw new Error('Invalid credentials')
}

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      aria-busy={pending}
      className={pending ? 'opacity-50 cursor-not-allowed' : ''}
    >
      {pending ? (
        <>
          <span className="inline-block animate-spin mr-2">โณ</span>
          Signing in...
        </>
      ) : (
        'Sign In'
      )}
    </button>
  )
}

export default function LoginForm() {
  return (
    <form action={authenticateUser} className="space-y-4">
      <div>
        <label htmlFor="email" className="block mb-2">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full px-4 py-2 border rounded"
        />
      </div>
      <div>
        <label htmlFor="password" className="block mb-2">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          className="w-full px-4 py-2 border rounded"
        />
      </div>
      <SubmitButton />
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {/* Screen reader announcements handled by button's aria-busy */}
      </div>
    </form>
  )
}

The SubmitButton component extracts form submission state through useFormStatus. When pending is true, the button displays a loading spinner and disables itself. The aria-busy attribute tells screen readers the form is processing, triggering an automatic announcement when the state changes. The disabled attribute prevents double-submission. Without it, users can click the submit button multiple times during network latency, sending duplicate requests. The cursor-not-allowed class provides visual feedback that the button is temporarily inactive.

Notice the button lives in a separate component from the form itself. This is non-negotiable-useFormStatus requires a parent <form> element in the component tree. Calling the hook directly in LoginForm would fail because the hook needs to access the form's submission context through React's fiber tree.

The aria-live="polite" region provides a fallback for complex screen reader announcements. In this simple case, aria-busy on the button handles status updates automatically. For more complex forms with multiple steps or validation errors, the live region becomes the primary announcement mechanism.

useFormState (useActionState): Managing Server-Side Validation

useFormState connects server actions to client-side validation by managing the action's return value as component state. The hook takes two arguments: a server action function and an initial state value. It returns a tuple containing the current state and a wrapped action to use in the form's action prop.

The server action receives two parameters: the previous state and the FormData object from the submission. The function processes the form data, performs validation, and returns a new state object. This state object typically contains error messages, success flags, or validated data that the component uses to render feedback.

The hook's integration with React Server Components means that validation logic executes on the server, protecting sensitive business rules from client-side inspection. The state object flows back to the client component through React's serialization boundary, automatically hydrating the UI with validation feedback.

This matters because most form libraries manage validation state client-side, duplicating validation logic between client and server. When client-side validation checks if an email is unique, the server must re-check the database anyway. With useFormState, validation runs once on the server, and the client receives the authoritative result.

The failure mode here is subtle but expensive: forgetting to reset form state after a successful submission. The useFormState hook does not automatically clear the form or reset its state. Teams must manually call form.reset() or redirect the user after successful submission. Without this cleanup, submitting the form a second time sends the previous submission's state to the server action, creating confusing error messages.

Complete Contact Form with Validation and Error Handling

A production contact form needs field-level validation, global error handling, success confirmation, and progressive enhancement. useFormState provides all of this through a single state object that tracks validation errors, submission status, and user feedback.

'use client'
import { useFormState } from 'react'
import { useFormStatus } from 'react'

interface ContactFormState {
  errors?: {
    name?: string
    email?: string
    message?: string
  }
  success?: boolean
  message?: string
}

async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  'use server'
  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  const errors: ContactFormState['errors'] = {}

  if (!name || name.trim().length < 2) {
    errors.name = 'Name must be at least 2 characters'
  }
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.email = 'Please enter a valid email address'
  }
  if (!message || message.trim().length < 10) {
    errors.message = 'Message must be at least 10 characters'
  }

  if (Object.keys(errors).length > 0) {
    return { errors }
  }

  // Simulate API call
  await new Promise(resolve => setTimeout(resolve, 1500))

  // Simulate occasional server error
  if (Math.random() > 0.8) {
    return { success: false, message: 'Server error. Please try again later.' }
  }

  return { success: true, message: 'Thank you for your message. We will respond within 24 hours.' }
}

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      aria-busy={pending}
      className="w-full bg-blue-600 text-white px-6 py-3 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
    >
      {pending ? 'Sending...' : 'Send Message'}
    </button>
  )
}

export default function ContactForm() {
  const initialState: ContactFormState = {}
  const [state, formAction] = useFormState(submitContactForm, initialState)

  return (
    <form action={formAction} className="max-w-2xl mx-auto space-y-6">
      {state.success === true && (
        <div
          role="status"
          aria-live="polite"
          className="p-4 bg-green-100 text-green-800 rounded border border-green-300"
        >
          {state.message}
        </div>
      )}
      {state.success === false && state.message && (
        <div
          role="alert"
          aria-live="assertive"
          className="p-4 bg-red-100 text-red-800 rounded border border-red-300"
        >
          {state.message}
        </div>
      )}

      <div>
        <label htmlFor="name" className="block mb-2 font-medium">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          aria-invalid={!!state.errors?.name}
          aria-describedby={state.errors?.name ? 'name-error' : undefined}
          className="w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500 aria-[invalid=true]:border-red-500"
        />
        {state.errors?.name && (
          <p id="name-error" role="alert" className="mt-1 text-sm text-red-600">
            {state.errors.name}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block mb-2 font-medium">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          aria-invalid={!!state.errors?.email}
          aria-describedby={state.errors?.email ? 'email-error' : undefined}
          className="w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500 aria-[invalid=true]:border-red-500"
        />
        {state.errors?.email && (
          <p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
            {state.errors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block mb-2 font-medium">Message</label>
        <textarea
          id="message"
          name="message"
          required
          rows={6}
          aria-invalid={!!state.errors?.message}
          aria-describedby={state.errors?.message ? 'message-error' : undefined}
          className="w-full px-4 py-2 border rounded focus:ring-2 focus:ring-blue-500 aria-[invalid=true]:border-red-500"
        />
        {state.errors?.message && (
          <p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
            {state.errors.message}
          </p>
        )}
      </div>

      <SubmitButton />
    </form>
  )
}

The submitContactForm server action validates each field and returns either an error object or a success message. The useFormState hook exposes this return value as the first element in its tuple. The component renders validation errors next to their corresponding fields using aria-invalid and aria-describedby to connect error messages to inputs.

The aria-invalid attribute tells screen readers that a field contains invalid data. The aria-describedby attribute links the error message to the input, so screen readers announce "Email, invalid, Please enter a valid email address" when the field receives focus. Without these attributes, screen readers cannot identify which fields have errors or what those errors are.

The global success and error messages use different aria-live regions. Success messages use aria-live="polite", allowing screen readers to finish their current announcement before reading the success message. Error messages use aria-live="assertive", which interrupts the current announcement to immediately notify users of the error.

Comments

No comments yet. Start the discussion.