Skip to main content

React 19 Features Every Developer Should Know

·PkgPulse Team

TL;DR

React 19 is the most impactful React release since hooks. The Actions API simplifies async state mutations by 80% — no more hand-rolling loading/error/success states. The use() hook enables promise and context consumption anywhere in a component. The React Compiler (formerly React Forget) eliminates most useMemo/useCallback needs. These aren't incremental improvements — they change how you write React day-to-day. Most teams on Next.js 15 are already using React 19 features without realizing it.

Key Takeaways

  • Actions API: async transitions that auto-handle pending/error states — replaces manual loading state management
  • use() hook: read promises and context anywhere, with Suspense integration
  • React Compiler: automatic memoization — delete most of your useMemo/useCallback
  • useOptimistic: optimistic updates with automatic rollback on error
  • ref as prop: no more forwardRef wrapper — pass ref directly as a prop

Actions API: The Biggest Ergonomics Win

// Before React 19 — manual async state management:
function SubmitButton() {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsPending(true);
    setError(null);
    try {
      await submitForm(new FormData(e.target as HTMLFormElement));
      setSuccess(true);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Something went wrong');
    } finally {
      setIsPending(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="text-red-500">{error}</p>}
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

// React 19 — Actions + useActionState:
function SubmitButton() {
  const [state, submitAction, isPending] = useActionState(
    async (prevState: { error: string | null }, formData: FormData) => {
      try {
        await submitForm(formData);
        return { error: null };
      } catch (err) {
        return { error: err instanceof Error ? err.message : 'Failed' };
      }
    },
    { error: null }
  );

  return (
    <form action={submitAction}>
      {state.error && <p className="text-red-500">{state.error}</p>}
      <button disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

// The gains:
// → No manual isPending management
// → No try/catch noise in the component
// → Works with <form action={...}> — progressive enhancement
// → Works with Server Actions in Next.js (same API)

useOptimistic: Instant UI Without Race Conditions

// Optimistic updates — classic pattern (pre-React 19):
function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, setOptimisticTodos] = useState(todos);

  const handleToggle = async (id: string) => {
    // Optimistically update UI
    setOptimisticTodos(prev =>
      prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
    try {
      await toggleTodo(id);
    } catch {
      // Revert on error — manually
      setOptimisticTodos(todos);
    }
  };
  // Problem: managing sync between optimisticTodos and real todos is error-prone
}

// React 19 — useOptimistic:
function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state: Todo[], optimisticValue: { id: string; done: boolean }) =>
      state.map(t =>
        t.id === optimisticValue.id ? { ...t, done: optimisticValue.done } : t
      )
  );

  const handleToggle = async (id: string, currentDone: boolean) => {
    // Optimistically update immediately
    addOptimistic({ id, done: !currentDone });
    // Actually update on server
    await toggleTodo(id);
    // If toggleTodo throws: React automatically reverts to the real todos
  };

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} className={todo.done ? 'opacity-50' : ''}>
          {todo.text}
          <button onClick={() => handleToggle(todo.id, todo.done)}>
            Toggle
          </button>
        </li>
      ))}
    </ul>
  );
}
// Automatic rollback on error — no manual revert code needed

The use() Hook: Flexible Async and Context

// React 19 — use() hook reads promises and context, anywhere:

// 1. Reading a promise with Suspense:
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  // use() suspends if the promise is pending
  // Works in regular function components — not just at the top level
  const user = use(userPromise);

  return <h1>{user.name}</h1>;
}
// Parent wraps in Suspense:
<Suspense fallback={<Skeleton />}>
  <UserProfile userPromise={fetchUser(id)} />
</Suspense>

// 2. Conditional context reading (impossible with useContext):
function ThemeButton({ showTheme }: { showTheme: boolean }) {
  // useContext must be called unconditionally
  // use() can be called conditionally:
  if (showTheme) {
    const theme = use(ThemeContext); // ✅ Valid in React 19
    return <button style={{ background: theme.primary }}>Themed</button>;
  }
  return <button>Plain</button>;
}

// 3. Combining with Server Components (Next.js pattern):
// In a Server Component:
async function Page() {
  const userPromise = fetchUser(); // Starts fetching immediately
  const postsPromise = fetchPosts(); // Starts fetching immediately

  return (
    <Suspense fallback={<Skeleton />}>
      {/* Pass promises to Client Components — they fetch in parallel */}
      <UserProfile userPromise={userPromise} />
      <PostList postsPromise={postsPromise} />
    </Suspense>
  );
}
// Both fetches start at the same time, stream as they resolve

React Compiler: Delete Your useMemo

// Before React Compiler — manual memoization required:
function ExpensiveList({ items, filter, onSelect }: Props) {
  // Need useMemo to prevent recomputing on unrelated re-renders
  const filteredItems = useMemo(
    () => items.filter(item => item.category === filter),
    [items, filter]
  );

  // Need useCallback to prevent child re-renders
  const handleSelect = useCallback(
    (id: string) => onSelect(id),
    [onSelect]
  );

  // Need to wrap child in React.memo to prevent unnecessary renders
  return (
    <ul>
      {filteredItems.map(item => (
        <MemoizedItem key={item.id} item={item} onSelect={handleSelect} />
      ))}
    </ul>
  );
}
// 3 separate "performance hooks" for one component

// After React Compiler — compiler handles this automatically:
function ExpensiveList({ items, filter, onSelect }: Props) {
  // No useMemo, no useCallback, no React.memo needed
  const filteredItems = items.filter(item => item.category === filter);
  const handleSelect = (id: string) => onSelect(id);

  return (
    <ul>
      {filteredItems.map(item => (
        <Item key={item.id} item={item} onSelect={handleSelect} />
      ))}
    </ul>
  );
}
// The compiler understands when values change and skips re-renders automatically

// How to enable (Next.js 15 — it's opt-in):
// next.config.ts:
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

// Standalone React + Babel:
// babel.config.json:
{
  "plugins": ["babel-plugin-react-compiler"]
}

// Current status (2026):
// → Stable, shipping in production at Meta
// → Next.js 15: opt-in experimental flag
// → Vite plugin available
// → Some edge cases (improper use of refs, mutation of props) may not compile

Smaller But Impactful Changes

// 1. ref as prop (no more forwardRef):

// Before React 19:
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
  <input {...props} ref={ref} />
));

// React 19 — ref is just a prop:
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input {...props} ref={ref} />;
}
// Or with the new type shorthand:
function Input({ ref, ...props }: React.ComponentProps<'input'>) {
  return <input {...props} ref={ref} />;
}
// forwardRef still works for backward compatibility

// 2. Document metadata in components (no more react-helmet):
function ProductPage({ product }: { product: Product }) {
  return (
    <>
      {/* React 19: these render in <head>, not in the div */}
      <title>{product.name} | Store</title>
      <meta name="description" content={product.description} />
      <link rel="canonical" href={`https://store.com/products/${product.id}`} />

      <main>...</main>
    </>
  );
}
// Works in both Server Components and Client Components
// Deduplication: multiple <title> in the tree? Last one wins.

// 3. Async scripts with deduplication:
function Analytics() {
  return (
    <script
      async
      src="https://analytics.example.com/script.js"
      data-website-id="abc123"
    />
  );
}
// React 19 deduplicates: even if rendered 100 times, script loads once

// 4. Stylesheet loading with priority:
<link rel="stylesheet" href="/styles/component.css" precedence="default" />
// React manages insertion order based on precedence
// Solves the CSS-in-JS stylesheet ordering problem for Server Components

Track React and related package health at PkgPulse.

See the live comparison

View react vs. vue on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.