DEV Community

Cover image for React 19 useActionState — from useState chaos to server-action clarity
Monalisa Das
Monalisa Das

Posted on Originally published at monalisadas-knowme.vercel.app

React 19 useActionState — from useState chaos to server-action clarity

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)