All insights
Engineering7 min read

React 19 Features Your Next.js Application Should Use

React 19 shipped significant changes to how forms, actions, and async state work. Next.js 16 is built on it — here's what's new and how to use it effectively.

NC

Nextcraft Engineering Team

The React 19 Mental Model Shift

React 19 formalizes patterns that were previously handled by third-party libraries (React Query, Formik, Zustand for async state). The core addition: first-class support for async transitions and form actions.

The mental model: instead of managing loading and error state manually with useState, React 19 tracks async state transitions automatically. This reduces boilerplate and makes async interactions more predictable.

useActionState: Forms Without the Ceremony

useActionState is the new hook for form actions. It replaces the pattern of managing separate loading, error, and data state variables:

code
'use client';

import { useActionState } from 'react';
import { createProject } from '@/lib/actions/project';

export function CreateProjectForm() {
  const [state, action, isPending] = useActionState(createProject, null);

  return (
    <form action={action}>
      <input name="name" placeholder="Project name" />
      
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      
      {state?.fieldErrors?.name && (
        <p className="text-red-500 text-sm">{state.fieldErrors.name}</p>
      )}
      
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Project'}
      </button>
    </form>
  );
}

The form works progressively — if JavaScript hasn't loaded, the native form submission still works (as long as the Server Action handles FormData). With JavaScript, it's enhanced with loading state.

useOptimistic: Instant UI Updates

useOptimistic provides a temporary optimistic state while an async operation is in progress:

code
'use client';

import { useOptimistic } from 'react';
import { deleteProject } from '@/lib/actions/project';

export function ProjectList({ projects }: { projects: Project[] }) {
  const [optimisticProjects, removeOptimistic] = useOptimistic(
    projects,
    (current, idToRemove: string) => current.filter(p => p.id !== idToRemove)
  );

  async function handleDelete(id: string) {
    removeOptimistic(id);  // Immediate UI update
    await deleteProject(id);  // Actual operation
    // On success: the server re-renders with the real data
    // On failure: React rolls back to the original state
  }

  return (
    <ul>
      {optimisticProjects.map(project => (
        <li key={project.id}>
          {project.name}
          <button onClick={() => handleDelete(project.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

The rollback on failure is automatic — useOptimistic tracks the original value and restores it if the async operation throws.

use(): Reading Promises in Render

The use() hook can unwrap a Promise during render. Unlike await in Server Components, use() works in Client Components and integrates with Suspense:

code
'use client';

import { use, Suspense } from 'react';

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // Suspends until promise resolves
  return <div>{user.name}</div>;
}

// Parent wraps with Suspense
export function Dashboard({ userPromise }: { userPromise: Promise<User> }) {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

More practically, use() can read context conditionally (unlike useContext, which must be called at the top level — though this distinction rarely matters in practice).

useTransition: Non-Urgent Updates

useTransition marks state updates as non-urgent, letting React keep the UI responsive while processing:

code
'use client';

import { useState, useTransition } from 'react';

export function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const newQuery = e.target.value;
    setQuery(newQuery); // Update input immediately

    startTransition(() => {
      // Filtering can be deferred — input stays responsive
      setFilteredItems(items.filter(item =>
        item.name.toLowerCase().includes(newQuery.toLowerCase())
      ));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} placeholder="Search..." />
      <div className={isPending ? 'opacity-60' : ''}>
        {filteredItems.map(item => <ItemRow key={item.id} item={item} />)}
      </div>
    </div>
  );
}

The input updates instantly. The filtered list updates in a transition — if the user types faster than the filter can compute, React interrupts the stale computation and starts fresh.

Form Actions Without useActionState

For simple forms that don't need client-side error display, forms can call Server Actions directly as their action:

code
// Server Action
'use server';
export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get('email') as string;
  await addSubscriber(email);
  redirect('/thank-you');
}

// Client form — no client state needed
export default function NewsletterForm() {
  return (
    <form action={subscribeToNewsletter}>
      <input name="email" type="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Progressive enhancement: no JavaScript needed for the form to work.

Improved Error Handling: onCaughtError and onUncaughtError

React 19 adds new root-level error callbacks for better error reporting:

code
// In your React root setup (not standard Next.js setup, but useful context)
const root = createRoot(container, {
  onCaughtError(error, errorInfo) {
    // Error caught by an error boundary
    reportToBugTracker(error, { componentStack: errorInfo.componentStack });
  },
  onUncaughtError(error, errorInfo) {
    // Error NOT caught by any error boundary
    // These would crash the app in React 18
    reportToBugTracker(error, { fatal: true });
  },
});

In Next.js, use global-error.tsx for the equivalent; these callbacks are more relevant for non-Next.js React applications.

The Practical Summary

For a Next.js App Router application, the React 19 features you'll use most:

FeatureReplacesUse case
useActionStateManual loading/error state + Server ActionsAll form submissions
useOptimisticManual optimistic update + rollback logicMutations that should feel instant
useTransitionDebouncing, manual interruption handlingSearch, filtering, non-urgent updates
use() with PromiseuseEffect + fetchRarely needed — Server Components cover most cases

React 19 closes the gap between "what React provides" and "what you need third-party libraries for" in the common case. The remaining cases for React Query and similar libraries: advanced caching strategies, background refetching, and complex server state synchronization.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.