React 19 New Features — Complete Guide for Next.js Developers in 2026

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

React 19 shipped with Next.js 15 as the default React version. It introduces a set of primitives that make form handling, async state, and optimistic UI dramatically simpler. Previously, you needed useState, useEffect, and manual loading/error tracking for every form. React 19 collapses this into a single hook in many cases.

The React Compiler (formerly React Forget) is also now stable — it automatically adds memoization where needed, eliminating most useMemo and useCallback calls. For Next.js developers, this means less boilerplate and better performance by default.

useActionState — The New Standard for Forms

useActionState (previously useFormState from react-dom) manages form state through Server Actions. It replaces the useState + useEffect + manual error handling pattern:

// app/actions.ts
'use server'
 
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
 
const schema = z.object({
  title: z.string().min(1, 'Title is required'),
  content: z.string().min(10, 'Content must be at least 10 characters'),
})
 
type FormState = {
  success: boolean
  message: string
  errors?: Record<string, string[]>
}
 
export async function createPost(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const raw = {
    title: formData.get('title'),
    content: formData.get('content'),
  }
 
  const result = schema.safeParse(raw)
 
  if (!result.success) {
    return {
      success: false,
      message: 'Validation failed',
      errors: result.error.flatten().fieldErrors,
    }
  }
 
  await prisma.post.create({ data: result.data })
  revalidatePath('/blog')
 
  return { success: true, message: 'Post created successfully' }
}
// app/create/page.tsx
'use client'
 
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
 
const initialState = { success: false, message: '' }
 
export default function CreatePostPage() {
  const [state, formAction, isPending] = useActionState(createPost, initialState)
 
  return (
    <form action={formAction} className="space-y-4 max-w-lg">
      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
 
      <div>
        <label htmlFor="title" className="block text-sm font-medium">Title</label>
        <input
          id="title"
          name="title"
          className="mt-1 w-full border rounded p-2"
        />
        {state.errors?.title && (
          <p className="text-red-600 text-sm mt-1">{state.errors.title[0]}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="content" className="block text-sm font-medium">Content</label>
        <textarea
          id="content"
          name="content"
          rows={6}
          className="mt-1 w-full border rounded p-2"
        />
        {state.errors?.content && (
          <p className="text-red-600 text-sm mt-1">{state.errors.content[0]}</p>
        )}
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
      >
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

useActionState returns [state, formAction, isPending]. The third element isPending replaces the need for useTransition in most form scenarios.

useFormStatus — Pending State Anywhere in the Form

useFormStatus reads the pending state of the nearest parent <form>. It must be used in a child component, not in the form component itself:

// components/submit-button.tsx
'use client'
 
import { useFormStatus } from 'react-dom'
 
export function SubmitButton({ label = 'Submit' }: { label?: string }) {
  const { pending } = useFormStatus()
 
  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50 flex items-center gap-2"
    >
      {pending && (
        <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
        </svg>
      )}
      {pending ? 'Processing...' : label}
    </button>
  )
}

Use SubmitButton inside any form that uses a Server Action as its action:

<form action={createPost}>
  <input name="title" />
  <SubmitButton label="Publish Post" />
</form>

useOptimistic — Instant UI with Rollback

useOptimistic shows an optimistic (assumed-successful) UI update immediately, then reverts if the server operation fails:

// app/todos/page.tsx
'use client'
 
import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from '@/app/actions'
 
interface Todo {
  id: string
  text: string
  completed: boolean
}
 
export function TodoList({ todos }: { todos: Todo[] }) {
  const [, startTransition] = useTransition()
 
  const [optimisticTodos, updateOptimistic] = useOptimistic(
    todos,
    (state: Todo[], updatedId: string) =>
      state.map((t) =>
        t.id === updatedId ? { ...t, completed: !t.completed } : t
      )
  )
 
  function handleToggle(id: string) {
    startTransition(async () => {
      updateOptimistic(id)   // immediate UI update
      await toggleTodo(id)   // actual server call
    })
  }
 
  return (
    <ul className="space-y-2">
      {optimisticTodos.map((todo) => (
        <li
          key={todo.id}
          className="flex items-center gap-3 cursor-pointer"
          onClick={() => handleToggle(todo.id)}
        >
          <span className={`w-4 h-4 border rounded ${todo.completed ? 'bg-blue-600' : ''}`} />
          <span className={todo.completed ? 'line-through text-gray-400' : ''}>
            {todo.text}
          </span>
        </li>
      ))}
    </ul>
  )
}

The UI toggles instantly. If toggleTodo throws, React reverts to the original state automatically.

useTransition with Async Functions

React 19 allows useTransition to wrap async functions, making it the right tool for async operations that should not block the UI:

'use client'
 
import { useTransition, useState } from 'react'
 
export function SearchBox() {
  const [results, setResults] = useState<string[]>([])
  const [isPending, startTransition] = useTransition()
 
  function handleSearch(query: string) {
    startTransition(async () => {
      const data = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
        .then(r => r.json())
      setResults(data)
    })
  }
 
  return (
    <div>
      <input
        type="search"
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search..."
        className="border p-2 rounded w-full"
      />
      {isPending && <p className="text-gray-400 mt-2">Searching...</p>}
      <ul className="mt-4 space-y-1">
        {results.map((r) => <li key={r}>{r}</li>)}
      </ul>
    </div>
  )
}

The React Compiler

The React Compiler analyzes your component code and automatically adds memoization where appropriate. You no longer need useMemo, useCallback, or React.memo in most cases:

// Before React Compiler — manual memoization
function ProductList({ products, filter }: Props) {
  const filtered = useMemo(
    () => products.filter((p) => p.category === filter),
    [products, filter]
  )
 
  const handleClick = useCallback((id: string) => {
    console.log('Clicked:', id)
  }, [])
 
  return filtered.map((p) => (
    <ProductCard key={p.id} product={p} onClick={handleClick} />
  ))
}
 
// After React Compiler — the compiler adds memoization automatically
function ProductList({ products, filter }: Props) {
  const filtered = products.filter((p) => p.category === filter)
 
  function handleClick(id: string) {
    console.log('Clicked:', id)
  }
 
  return filtered.map((p) => (
    <ProductCard key={p.id} product={p} onClick={handleClick} />
  ))
}

Enable the compiler in Next.js:

// next.config.ts
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
}

Improved Ref Handling

React 19 allows function components to accept ref as a prop without forwardRef:

// React 19 — ref as a prop, no forwardRef needed
function Input({
  label,
  ref,
  ...props
}: React.InputHTMLAttributes<HTMLInputElement> & { label: string; ref?: React.Ref<HTMLInputElement> }) {
  return (
    <div>
      <label className="block text-sm font-medium">{label}</label>
      <input ref={ref} className="mt-1 border rounded p-2 w-full" {...props} />
    </div>
  )
}
 
// Usage
function Form() {
  const inputRef = React.useRef<HTMLInputElement>(null)
 
  return (
    <form>
      <Input label="Email" type="email" ref={inputRef} />
    </form>
  )
}

Common Mistakes

  • Using useFormState from react-dom — it is renamed to useActionState in React 19 and imported from react
  • Putting useFormStatus in the same component as the <form> — it must be in a child component
  • Not wrapping updateOptimistic in startTransition — this is required for optimistic updates to work correctly
  • Enabling the React Compiler without testing — run it on one route first; some patterns with external mutable objects may need adjustment
  • Forgetting that useActionState requires the action function to accept (prevState, formData) as arguments

Best Practices

  • Use useActionState for all form submissions that call Server Actions — it handles pending, error, and success state in one hook
  • Extract SubmitButton as a reusable component that reads useFormStatus so any form gets loading state for free
  • Use useOptimistic for toggle and like actions where the expected outcome is almost always success
  • Validate form data with Zod inside the Server Action and return structured field errors to display inline
  • Enable the React Compiler incrementally — set compilationMode: 'annotation' to opt in per file

Key Takeaways

  • useActionState (from react, not react-dom) replaces useState + manual loading/error tracking for Server Action forms
  • useFormStatus must be used in a child component of the form — it cannot read from the same component that renders the form
  • useOptimistic updates the UI immediately and auto-reverts if the async operation fails
  • React 19 allows ref as a direct prop on function components — forwardRef is no longer required
  • The React Compiler is now stable and can be enabled in Next.js with experimental.reactCompiler: true
  • useTransition in React 19 accepts async functions, making it the standard for async state transitions
  • useActionState third return value isPending replaces the separate useTransition + useState loading pattern
  • All React 19 hooks (useActionState, useOptimistic) work with both Client Components and Server Components via Server Actions

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading