Frameworks7 min read

React 19: New Features and Breaking Changes Explained

Explore React 19's groundbreaking features including the new compiler, server components improvements, and actions API that will transform how you build React applications.

Zeeshan Shahid
Zeeshan Shahid
February 10, 2025
Share:
React 19: New Features and Breaking Changes Explained

React 19 is less a bag of new features than a shift in who does the work. The compiler takes over memoization you used to write by hand. Server Components move data fetching off the client. Actions absorb the form boilerplate every React app reimplements. In each case, React 19's answer to a familiar chore is "you shouldn't have to do that."

Versions and APIs move

React 19 shipped as stable in December 2024. The React Compiler shipped separately and on its own timeline, and its configuration has changed across releases. Check the current React documentation before copying any compiler config here into a project.

1. The React Compiler

The Problem It Solves

Manual memoization is the tax React developers pay for React's rendering model. useMemo, useCallback, and React.memo don't express intent — they exist to work around re-renders. The compiler's premise is that a build tool can determine what needs memoizing more reliably than a human can.

Before and After

Here's a typical dashboard component under manual optimization:

// React 18 — manual memoization
import React, { useMemo, useCallback, memo } from 'react';

const DashboardStats = memo(({ data, filters, onFilterChange }) => {
  const filteredData = useMemo(() => {
    return data.filter(item =>
      filters.includes(item.category) && item.value > 0
    );
  }, [data, filters]);

  const totalValue = useMemo(() => {
    return filteredData.reduce((sum, item) => sum + item.value, 0);
  }, [filteredData]);

  const averageValue = useMemo(() => {
    return filteredData.length > 0
      ? totalValue / filteredData.length
      : 0;
  }, [totalValue, filteredData.length]);

  const topItems = useMemo(() => {
    return [...filteredData]
      .sort((a, b) => b.value - a.value)
      .slice(0, 10);
  }, [filteredData]);

  const handleFilterToggle = useCallback((category) => {
    onFilterChange(prev =>
      prev.includes(category)
        ? prev.filter(c => c !== category)
        : [...prev, category]
    );
  }, [onFilterChange]);

  return (
    <div className="dashboard">
      <StatCard title="Total" value={totalValue} />
      <StatCard title="Average" value={averageValue} />
      <FilterBar filters={filters} onToggle={handleFilterToggle} />
      <TopItemsList items={topItems} />
    </div>
  );
});

const StatCard = memo(({ title, value }) => (
  <div className="stat-card">
    <h3>{title}</h3>
    <p>{value.toLocaleString()}</p>
  </div>
));

The same component, with the compiler handling memoization:

// React 19 with the compiler — plain JavaScript
function DashboardStats({ data, filters, onFilterChange }) {
  const filteredData = data.filter(item =>
    filters.includes(item.category) && item.value > 0
  );

  const totalValue = filteredData.reduce((sum, item) => sum + item.value, 0);
  const averageValue = filteredData.length > 0
    ? totalValue / filteredData.length
    : 0;

  const topItems = [...filteredData]
    .sort((a, b) => b.value - a.value)
    .slice(0, 10);

  const handleFilterToggle = (category) => {
    onFilterChange(prev =>
      prev.includes(category)
        ? prev.filter(c => c !== category)
        : [...prev, category]
    );
  };

  return (
    <div className="dashboard">
      <StatCard title="Total" value={totalValue} />
      <StatCard title="Average" value={averageValue} />
      <FilterBar filters={filters} onToggle={handleFilterToggle} />
      <TopItemsList items={topItems} />
    </div>
  );
}

// Plain function components — no memo wrapper
function StatCard({ title, value }) {
  return (
    <div className="stat-card">
      <h3>{title}</h3>
      <p>{value.toLocaleString()}</p>
    </div>
  );
}

The second version isn't just shorter. It's the code you would have written if performance weren't a consideration — which is the point.

How the Compiler Works

The compiler performs static analysis on your components to understand data flow, then inserts memoization where it determines it's beneficial. It relies on your components following the Rules of React: pure render functions, no mutation of props or state during render, hooks called unconditionally.

When it can't prove a component is safe to optimize, it skips it rather than risking incorrect behaviour. This is the important design decision — the compiler bails out rather than guessing.

Adopting the Compiler

npm install -D babel-plugin-react-compiler

Start in opt-in mode so you can adopt it file by file rather than all at once:

{
  "plugins": [
    ["react-compiler", {
      "compilationMode": "annotation"
    }]
  ]
}

In annotation mode, the compiler only touches files or functions carrying the "use memo" directive:

'use memo';

export function CriticalComponent() {
  // Compiled
}

There's a corresponding "use no memo" escape hatch for components that misbehave under compilation.

The compiler is a lint result, not just an optimization

Install eslint-plugin-react-compiler before enabling compilation. It reports the same rule violations the compiler detects — mutation during render, conditional hooks, side effects in render bodies. If the compiler is silently bailing out of half your components, the lint rule is what tells you why. Fixing those violations is worth doing regardless of whether you adopt the compiler, because they're latent bugs under concurrent rendering.


2. Server Components

Server Components run on the server, never ship to the client, and can fetch data directly. They were experimental in React 18 and are a supported part of React 19 — in practice, via a framework that implements them.

Server Component Example

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';

// Runs on the server — this component's code never reaches the browser
export default async function ProductPage({ params }) {
  // Parallel fetching — these don't wait for each other
  const [product, reviews, recommendations] = await Promise.all([
    fetchProduct(params.id),
    fetchReviews(params.id),
    fetchRecommendations(params.id),
  ]);

  if (!product) {
    notFound();
  }

  return (
    <div className="product-page">
      {/* Critical content renders immediately */}
      <ProductHero product={product} />

      {/* Non-critical content streams in as it resolves */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews reviews={reviews} productId={params.id} />
      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations items={recommendations} />
      </Suspense>
    </div>
  );
}

async function fetchProduct(id) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 3600 }
  });

  if (!res.ok) return null;
  return res.json();
}

Client Components for Interactivity

// components/AddToCartButton.tsx
'use client';

import { useState, useOptimistic } from 'react';

export function AddToCartButton({ productId, initialQuantity = 1 }) {
  const [quantity, setQuantity] = useState(initialQuantity);
  const [optimisticCart, addOptimisticCart] = useOptimistic(
    { items: [] },
    (state, newItem) => ({
      items: [...state.items, newItem]
    })
  );

  async function handleAddToCart() {
    // UI updates immediately
    addOptimisticCart({ productId, quantity });

    // Network request happens behind it
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId, quantity })
    });
  }

  return (
    <div className="add-to-cart">
      <QuantitySelector value={quantity} onChange={setQuantity} min={1} max={99} />
      <button onClick={handleAddToCart} className="btn-primary">
        Add to Cart
      </button>
    </div>
  );
}

Why This Architecture Helps

The structural argument for Server Components doesn't need benchmarks:

  • Component code stays on the server. A date-formatting library used only in a Server Component contributes zero bytes to the client bundle.
  • Data fetching happens next to the data. No client-side request waterfall where the browser must download JS, execute it, discover it needs data, then request it.
  • Markup arrives populated. Content is in the initial HTML rather than assembled after hydration.
  • Streaming decouples slow from fast. A slow reviews query no longer blocks the product title from rendering.
Everything crossing the boundary must be serializable

Props passed from a Server Component to a Client Component are serialized. Functions, class instances, Dates in some configurations, and Symbols won't survive. The error messages here are much better in React 19 than they were, but the constraint still shapes how you structure components — pass data down, and keep event handlers inside the client boundary.


3. The Actions API

Actions turn form submission into something React manages, including pending state, errors, and optimistic updates.

The Old Way

// React 18 — manual everything
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const router = useRouter();

  async function handleSubmit(e) {
    e.preventDefault();
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });

      if (!response.ok) {
        throw new Error('Login failed');
      }

      const data = await response.json();
      router.push('/dashboard');
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        disabled={loading}
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        disabled={loading}
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Log In'}
      </button>
    </form>
  );
}

Four pieces of state, and none of them are the actual feature.

The Actions Way

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

async function loginAction(prevState, formData) {
  'use server';

  const email = formData.get('email');
  const password = formData.get('password');

  try {
    await authenticate(email, password);
    redirect('/dashboard');
  } catch (error) {
    return { error: 'Invalid credentials. Please try again.' };
  }
}

function LoginForm() {
  const [state, formAction] = useActionState(loginAction, {});

  return (
    <form action={formAction}>
      {state?.error && <div className="error">{state.error}</div>}

      {/* Uncontrolled inputs — formData collects them */}
      <input type="email" name="email" required />
      <input type="password" name="password" required />

      <SubmitButton />
    </form>
  );
}

// useFormStatus reads the parent form's state — no prop drilling
function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Logging in...' : 'Log In'}
    </button>
  );
}
useFormState was renamed to useActionState

If you're following a tutorial written during the React 19 betas, it may import useFormState from react-dom. That hook was renamed to useActionState and moved to the react package. useFormStatus still lives in react-dom. This trips people up constantly during upgrades.

Optimistic Updates

'use client';

import { useOptimistic } from 'react';
import { deleteComment } from './actions';

function CommentList({ comments }) {
  const [optimisticComments, removeOptimisticComment] = useOptimistic(
    comments,
    (state, commentId) => state.filter(c => c.id !== commentId)
  );

  async function handleDelete(commentId) {
    // UI updates instantly
    removeOptimisticComment(commentId);

    // If this throws, React reverts the optimistic state automatically
    await deleteComment(commentId);
  }

  return (
    <ul>
      {optimisticComments.map(comment => (
        <li key={comment.id}>
          <p>{comment.text}</p>
          <button onClick={() => handleDelete(comment.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

The automatic revert on failure is the part worth appreciating. Hand-rolled optimistic updates usually get the happy path right and the rollback wrong.

Progressive Enhancement

Because Actions are built on native form submission, a form wired to a server action works before JavaScript loads — and works better once it does:

async function subscribe(formData) {
  'use server';

  const email = formData.get('email');
  await saveToDatabase(email);
  redirect('/thank-you');
}

export function NewsletterForm() {
  return (
    <form action={subscribe}>
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

4. The use() Hook

use() reads a resource — a Promise or a Context — and suspends until it resolves:

import { use, Suspense } from 'react';

function UserProfile({ userPromise }) {
  // Suspends until the promise resolves
  const user = use(userPromise);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

function App() {
  // Fetch starts here, during render — not after mount
  const userPromise = fetchUser('123');

  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

The contrast with the useEffect pattern is the point:

// Old: mount, then fetch — the request can't start until after render
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <Skeleton />;
  return <div>{user.name}</div>;
}

use() also breaks a long-standing rule: unlike other hooks, it can be called conditionally and inside loops.

Don't create promises during render in Client Components

use(fetchUser('123')) inside a Client Component creates a new promise on every render, which suspends, which re-renders, which creates another promise. Promises passed to use() should come from a Server Component or a cache that returns a stable reference. This is the single most common way people get use() wrong.


5. Document Metadata

<title>, <meta>, and <link> tags rendered anywhere in the tree are now hoisted into <head> automatically — no third-party library required:

// React 18 — needed react-helmet or similar
import { Helmet } from 'react-helmet-async';

function BlogPost({ post }) {
  return (
    <>
      <Helmet>
        <title>{post.title} | My Blog</title>
        <meta name="description" content={post.excerpt} />
      </Helmet>
      <article>{post.content}</article>
    </>
  );
}
// React 19 — native
function BlogPost({ post }) {
  return (
    <article>
      <title>{post.title} | My Blog</title>
      <meta name="description" content={post.excerpt} />
      <meta property="og:title" content={post.title} />
      <meta property="og:image" content={post.coverImage} />
      <meta property="og:type" content="article" />
      <link rel="canonical" href={`https://myblog.com/posts/${post.slug}`} />

      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

React 19 also adds native support for stylesheet precedence and async script deduplication, which cover the other common reasons people reached for head-management libraries.


Breaking Changes

Removed APIs

// REMOVED: legacy Context API
class OldContext extends React.Component {
  static childContextTypes = { color: PropTypes.string };
}

// USE: modern Context
const ThemeContext = createContext('light');
// REMOVED: string refs
<input ref="myInput" />

// USE: useRef or callback refs
const inputRef = useRef();
<input ref={inputRef} />
// REMOVED: defaultProps on function components
function Button({ size, variant }) { /* ... */ }
Button.defaultProps = { size: 'medium', variant: 'primary' };

// USE: destructuring defaults
function Button({ size = 'medium', variant = 'primary' }) { /* ... */ }

React 19 also removed propTypes on function components (TypeScript is the intended replacement), React.createFactory, and module pattern factory components.

Improvements Worth Knowing

  • ref is now a regular prop for function components — forwardRef is no longer required for new code.
  • Better hydration error messages, including a diff of what mismatched rather than a generic warning.
  • <Context> renders as a provider directly — <Context.Provider> is no longer necessary.

Stricter StrictMode

StrictMode catches more issues in development: double-invoked effects, side effects during render, and missing cleanup functions. These aren't new bugs — StrictMode surfaces problems that were always there and would eventually bite under concurrent rendering.


Common Pitfalls

Over-Trusting the Compiler

// The compiler can't help — Math.random() is impure
function BadComponent({ data }) {
  const randomId = Math.random();
  return <div id={randomId}>{data}</div>;
}

// Stable across renders
function GoodComponent({ data }) {
  const id = useId();
  return <div id={id}>{data}</div>;
}

The compiler optimizes code that follows the Rules of React. Code that violates them gets skipped — and impure render functions were already a bug.

Server/Client Boundary Mistakes

// Breaks — functions can't be serialized across the boundary
<ServerComponent onClick={() => console.log('hi')} />

// Pass data; keep handlers in Client Components
<ServerComponent data={data} />

Forgetting 'use client'

// Breaks — useState requires a Client Component
export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// Fixed
'use client';

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Patterns That Work

Push Client Components to the Leaves

// app/posts/[id]/page.tsx — Server Component
export default async function PostPage({ params }) {
  const post = await fetchPost(params.id);

  return (
    <article>
      <title>{post.title}</title>
      <PostHeader post={post} />
      <PostContent content={post.content} />
      {/* Only the interactive bits are client components */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
      <CommentSection postId={post.id} />
    </article>
  );
}

// components/LikeButton.tsx
'use client';

export function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  // ...
}

'use client' is a boundary, not a file-level annotation — everything imported below it joins the client bundle. Marking a top-level layout as a Client Component pulls your entire tree along with it. Keep the directive as far down the tree as possible.

Parallel vs Streaming Data Fetching

// Fetch in parallel when you need everything before rendering
async function Dashboard() {
  const [user, stats, notifications] = await Promise.all([
    fetchUser(),
    fetchStats(),
    fetchNotifications()
  ]);

  return (
    <>
      <UserCard user={user} />
      <StatsGrid stats={stats} />
      <NotificationList notifications={notifications} />
    </>
  );
}

// Stream when some data is slower and less critical
async function Dashboard() {
  const user = await fetchUser(); // Critical — block on it

  return (
    <>
      <UserCard user={user} />

      <Suspense fallback={<StatsSkeleton />}>
        <StatsGrid />
      </Suspense>

      <Suspense fallback={<NotificationsSkeleton />}>
        <NotificationList />
      </Suspense>
    </>
  );
}

Sequential awaits in a Server Component create a server-side waterfall — the classic mistake when migrating from useEffect-based fetching.


Should You Upgrade?

Upgrade now if:

  • You're starting a new project
  • You're already on a framework with React 19 support
  • Your app has accumulated significant memoization boilerplate
  • You want Actions and native metadata support

Wait if:

  • Critical dependencies haven't published React 19-compatible versions
  • You depend on removed APIs (string refs, legacy context, defaultProps on function components)
  • Your build tooling is heavily customized
  • Your team needs time to learn the Server/Client model, which is a genuine mental shift

The upgrade path itself is well-supported: React publishes codemods for the removed APIs alongside the upgrade guide, and React 18.3 emits warnings for everything React 19 removes — running it first surfaces most of your work before you switch.


Resources

Our Take

Key Takeaway

React 19's features share a direction: move work from the developer to the framework. The compiler handles memoization, Server Components handle the bundle/data tradeoff, and Actions handle form state. Each one deletes code you only wrote because React made you.

The pragmatic sequencing: upgrade to React 19 first — it's mostly non-breaking and the codemods handle the removals. Adopt Actions next, since they're self-contained and immediately delete boilerplate. Add the compiler in annotation mode with the ESLint plugin running, and treat the violations it reports as a bug list rather than compiler complaints.

Save Server Components for last if you're migrating an existing app. They're the highest-value change and the one that most reshapes how you think about a React codebase — which also makes them the one you don't want to rush.

Tags:reactjavascriptfrontendweb-developmentreact-19
Zeeshan Shahid

Zeeshan Shahid

Founder, DevPages

Zeeshan builds and maintains DevPages, a hand-curated directory of developer tools. He writes about the tools in the catalog and the trade-offs between them.

22 articles published

Related Articles