React 19 new hooks in Next.js App Router: what actually changed for me
React 19's new hooks — useFormStatus, useActionState, and use() — aren't just quality-of-life additions. In the context of the Next.js App Router and Server Actions, they change the fundamental shape of how I write forms and async UI. Here's what that looks like in practice, without the hype.
The problem these hooks are solving
Before React 19, building a form that called a Server Action and reflected loading/error state required a fair amount of ceremony. You'd reach for useState + useTransition, manually thread the pending flag down to a submit button, and handle error returns with your own convention. It worked, but it was boilerplate you wrote every time.
React 19 formalises that pattern into three primitives. I'll go through each one with a concrete before/after.
useActionState replaces the useState + useTransition combo
Before React 19, a typical contact form Server Action pattern looked like this:
// app/contact/page.tsx (before React 19)
'use client'
import { useTransition, useState } from 'react'
import { submitContact } from './actions'
export default function ContactForm() {
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
startTransition(async () => {
const result = await submitContact(formData)
if (result.error) setError(result.error)
else setSuccess(true)
})
}
return (
<form onSubmit={handleSubmit}>
{error && <p>{error}</p>}
{success && <p>Sent!</p>}
<input name="email" type="email" />
<button disabled={isPending}>{isPending ? 'Sending…' : 'Send'}</button>
</form>
)
}
With useActionState this collapses significantly:
// app/contact/page.tsx (React 19)
'use client'
import { useActionState } from 'react'
import { submitContact } from './actions'
type State = { error?: string; success?: boolean }
const initialState: State = {}
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, initialState)
return (
<form action={formAction}>
{state.error && <p>{state.error}</p>}
{state.success && <p>Sent!</p>}
<input name="email" type="email" />
<button disabled={isPending}>{isPending ? 'Sending…' : 'Send'}</button>
</form>
)
}
The Server Action now receives (prevState: State, formData: FormData) as its signature. The hook owns the pending flag and state transitions. I no longer have to wire e.preventDefault() or startTransition manually — the action prop on <form> handles that natively when given a function.
One thing worth noting: your Server Action signature must change to accept prevState as the first argument when used with useActionState. If you're migrating existing actions, that's the breaking change to watch for.
useFormStatus pushes pending state down without prop drilling
The submit button sitting inside a form has no way of knowing the form is pending unless you pass the flag down as a prop. useFormStatus solves this by reading the parent form's status from context.
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Working…' : label}
</button>
)
}
This component can now be dropped into any form that uses a Server Action — or useActionState — and it automatically reflects pending state. No prop, no context setup, no wiring. It reads the nearest ancestor <form> that has a pending action.
The constraint is real: useFormStatus only works inside a component that is rendered as a child of the form , not inside the same component that renders the form. I've seen people trip on this. The fix is always the same — extract the button to its own component.
use() for async data and context unwrapping
use() is the strangest of the three because it does two different things depending on what you pass it.
For promises , it lets you pass a promise from a Server Component down to a Client Component and unwrap it there with Suspense. This is the pattern that replaces some useEffect-based data fetching:
// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react'
import { fetchUserStats } from '@/lib/data'
import { StatsPanel } from './stats-panel'
export default function DashboardPage() {
// Not awaited — passes the promise down
const statsPromise = fetchUserStats()
return (
<Suspense fallback={<p>Loading stats…</p>}>
<StatsPanel promise={statsPromise} />
</Suspense>
)
}
// app/dashboard/stats-panel.tsx (Client Component)
'use client'
import { use } from 'react'
import type { UserStats } from '@/lib/data'
export function StatsPanel({ promise }: { promise: Promise<UserStats> }) {
const stats = use(promise) // suspends until resolved
return <div>{stats.totalOrders} orders</div>
}
The Server Component starts the fetch, hands off the promise, and the Client Component suspends. The practical benefit over useEffect is that the fetch starts server-side and the waterfall is shorter.
For context , use(MyContext) is just useContext(MyContext) with one meaningful difference: you can call it conditionally. That matters in practice when you have context that only exists in certain render paths and you want to avoid the rules-of-hooks gymnastics.
What this means for how I structure App Router projects
These three hooks push me toward a cleaner separation. Server Actions stay as plain async functions with a typed return — no client state logic bleeds in. Client Components that render forms are slimmer because they don't own the pending/error state machinery anymore. And small components like SubmitButton can be genuinely reusable without receiving one-off props.
The shift I haven't fully resolved: useActionState pairs state and action together, which is convenient but means the action is tightly coupled to that particular state shape. If the same Server Action is called from two forms with different UX needs, I end up duplicating or wrapping. That's a real trade-off, not a deal-breaker, but worth knowing before you adopt this pattern everywhere.
Overall, these hooks feel like React acknowledging that Server Actions are a first-class pattern and building the client-side ergonomics to match. The before/after diff is substantial enough that I've started migrating existing forms as I touch them.
Discussion in the ATmosphere