React Form Hooks — useFormState, useFormStatus, and useActionState
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 < 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
useFormStatusat the form level itself instead of inside a child component — it will always returnpending: 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
useFormStatusrequires the component to be a descendant of<form>, not a sibling
Best Practices
- Extract
SubmitButtonas a separate component souseFormStatusworks 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
useFormStatustracks the pending state of the nearest parent form — always use it inside a child component, not the form itselfuseActionStatelinks a form to a Server Action and exposes the action's returned value as component state- Server Actions replace traditional API route
fetchcalls for form submissions, with less boilerplate - Return structured
fieldErrorsobjects from Server Actions to enable per-field validation messages - Combine
useFormStatusanduseActionStatein the same form for both a disabled button and error display - Server Actions automatically handle CSRF protection — no need to add tokens manually
- The
pendingproperty fromuseFormStatusistrueonly while the form submission is in-flight - These hooks work without JavaScript enabled because they fall back to native HTML form behavior
Advertisement
Related reading
React Hooks - The Complete Guide with Real-World Examples6 min readBuild an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readNext.js + tRPC Guide — End-to-End Type Safety Best Practices 20265 min readNext.js 15 New Features — Every Breaking Change and Upgrade Explained7 min readNext.js App Router — The Complete Guide for 20267 min readNext.js Server Actions — Replace API Routes with Type-Safe Server Functions 20267 min read