NextjsuseFormStatus & useActionState

useFormStatus & useActionState

Server Actions handle the mutation itself, but a form still needs to communicate back to the user: is the submission in flight, did it succeed, what error came back? Two hooks — useFormStatus from react-dom and useActionState from react — cover exactly that, without you having to hand-roll loading and result state.

useFormStatus: reading a parent form's pending state

useFormStatus reports the status of the nearest enclosing form — most usefully whether it is currently pending a submission. Its one strict rule: the component calling it must be rendered inside a form element as a descendant, since it reads status from that parent form, not from any form you pass it directly.

TSX
// components/submit-button.tsx
'use client'

import { useFormStatus } from 'react-dom'

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

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving…' : 'Save'}
    </button>
  )
}

TSX
// app/[locale]/settings/page.tsx
import { SubmitButton } from '@components/submit-button'
import { updateSettings } from './actions'

export default function SettingsPage() {
  return (
    <form action={updateSettings}>
      <input name="displayName" />
      {/* SubmitButton is a descendant of this form, so useFormStatus
          inside it correctly reads this form's pending state. */}
      <SubmitButton />
    </form>
  )
}
Warning
useFormStatus must be called from a component rendered inside the form, not from the same component that renders the form element itself. Calling it in the component that owns the form tag will not see that form's status — it needs a separate child component, as shown above.
useActionState: managing a Server Action's result

useActionState wraps a Server Action and gives you back its latest returned state, a wrapped action to pass to the form, and a pending flag — letting the action communicate structured results (like validation errors) back to the component that called it.

TSX
// app/[locale]/settings/actions.ts
'use server'

interface ActionResult {
  success: boolean
  error?: string
}

export async function updateSettings(
  previousState: ActionResult,
  formData: FormData,
): Promise<ActionResult> {
  const displayName = formData.get('displayName')

  if (typeof displayName !== 'string' || displayName.trim().length === 0) {
    return { success: false, error: 'Display name is required.' }
  }

  await saveDisplayName(displayName)

  return { success: true }
}

TSX
// app/[locale]/settings/settings-form.tsx
'use client'

import { useActionState } from 'react'
import { updateSettings } from './actions'
import { SubmitButton } from '@components/submit-button'

const initialState = { success: false }

export function SettingsForm() {
  const [state, formAction] = useActionState(updateSettings, initialState)

  return (
    <form action={formAction}>
      <input name="displayName" />
      <SubmitButton />
      {state.error && <p role="alert">{state.error}</p>}
      {state.success && <p>Saved!</p>}
    </form>
  )
}
Note
The action passed to useActionState receives the previous state as its first argument and the FormData as its second — a different signature from a plain Server Action used directly as a form's action, which only receives FormData.
Putting both hooks together

useFormStatus and useActionState solve different halves of the same problem and are commonly used side by side: useActionState on the form owner for the result of the last submission, and useFormStatus in a nested button component for the in-flight pending state.

Hook

Imported from

Reads

Must be used

useFormStatus

react-dom

The nearest parent form's pending/data/method

In a component rendered inside the form

useActionState

react

An action's returned state across submissions, plus a pending flag

In the component that renders the form and owns the action

Tip
Both hooks are fully compatible with progressive enhancement — the form built this way still works if JavaScript is disabled or hasn't loaded yet, because the underlying mechanism is a real form submission to a Server Action, not a fetch call wired up by client-side JavaScript.