Every form I've built in React has the same boilerplate: loading state, error state, success message — three useStates for one button click. React 19's useActionState replaces all of it.
Every form I've built in React has the same boilerplate. Loading state. Error state. Maybe a success message. Three useStates for one button click.
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
await submitContactForm(formData)
setSuccess(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}
I wrote this pattern in my first React project. I've written it a hundred times since. It's not wrong — it just shouldn't exist.
React 19 ships useActionState
One hook. Replaces all three.
import { useActionState } from 'react'
type State = { success: boolean; error: string | null }
async function submitAction(prevState: State, formData: FormData): Promise<State> {
try {
await submitContactForm(formData)
return { success: true, error: null }
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Failed' }
}
}
function ContactForm() {
const [state, action, isPending] = useActionState(submitAction, {
success: false,
error: null,
})
return (
<form action={action}>
<input name="email" type="email" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending…' : 'Send'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Sent!</p>}
</form>
)
}
No onSubmit. No e.preventDefault(). The action prop on the form handles it. isPending replaces the loading state. The returned state object replaces error and success. React manages the pending flag automatically — you just read it.
The hook signature
const [state, action, isPending] = useActionState(
actionFn, // async (prevState, formData) => newState
initialState, // what state is before the first submit
permalink? // optional URL for progressive enhancement
)
The action function always receives prevState as its first argument, then formData. It returns whatever becomes the new state — any shape you define. That state persists across renders and re-submits.
With server actions (Next.js 14+)
This is where it gets genuinely useful. The action can live on the server. No API route. No fetch. No JSON.stringify.
// app/actions.ts
'use server'
type State = { success: boolean; error: string | null }
export async function submitContactForm(
prevState: State,
formData: FormData
): Promise<State> {
const email = formData.get('email') as string
if (!email) return { success: false, error: 'Email is required' }
await db.contacts.insert({ email, createdAt: new Date() })
return { success: true, error: null }
}
// app/contact/page.tsx
'use client'
import { useActionState } from 'react'
import { submitContactForm } from '../actions'
export function ContactForm() {
const [state, action, isPending] = useActionState(submitContactForm, {
success: false,
error: null,
})
return (
<form action={action}>
<input name="email" type="email" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending…' : 'Send'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p>Done. I'll be in touch.</p>}
</form>
)
}
The server action crosses the boundary automatically. The client component doesn't know or care that the function runs on the server. The form works even with JavaScript disabled — the browser just submits it the old-fashioned way.
Two things I got wrong the first time
First: I kept passing the action to onSubmit instead of the form's action prop. The hook doesn't intercept events — it works with the native form submission. Pass it to action, not onSubmit.
Second: the action function signature is (prevState, formData), in that order. I kept writing async function submit(formData: FormData) and then wondering why formData looked wrong. prevState is always first, even if you ignore it.
Worth switching for?
Run the state machine demo — see how useActionState manages isPending and prevState without React
If you're managing form loading and error state manually — yes, immediately. Zero bundle cost, it's built into React 19. The refactor is maybe 15 minutes per form. You end up with less code, fewer useState calls, and cleaner components. If you're on Next.js and not yet using server actions, that's the bigger unlock — but useActionState works fine with regular async functions too. Start there.
Top comments (0)