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.
// 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>
)
}// 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>
)
}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.
// 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 }
}// 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>
)
}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 |