React 20 and the New Concurrency Primitives: What Actually Changed



Every major React release arrives with a new mental model to learn. React 16 brought Fiber. React 18 brought concurrent rendering and useTransition. React 20 — released in early 2026 — brings the React Compiler to stable, reshapes how Suspense works in server contexts, and introduces cache() as a first-class API for request-scoped memoization.

If you’ve been running Next.js 15+ or Remix, you’ve seen these changes in preview. This post is about what they actually mean for how you write components day-to-day.

Abstract visualization of data flow and component trees representing React architecture Photo by Unsplash+ on Unsplash

The React Compiler: What It Actually Does

The React Compiler (formerly “React Forget”) has been in labs for three years. The premise: React’s mental model is UI = f(state), but the performance model requires manual memoization (useMemo, useCallback, memo). The compiler makes memoization automatic.

This is not a small thing. Manual memoization is one of the most common sources of subtle bugs in React codebases. Either you over-memoize (wasting memory and adding complexity) or you under-memoize (getting stale data or performance problems).

Here’s what the compiler actually transforms:

// What you write:
function ProductCard({ product, onAddToCart }) {
  const discountedPrice = product.price * (1 - product.discount);
  const formattedPrice = formatCurrency(discountedPrice, product.currency);
  
  return (
    <div className="product-card">
      <img src={product.imageUrl} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{formattedPrice}</p>
      <button onClick={() => onAddToCart(product.id)}>
        Add to Cart
      </button>
    </div>
  );
}

// What the compiler emits (conceptually):
function ProductCard({ product, onAddToCart }) {
  const $ = _cache(7);
  
  let t0 = $.get(0);
  if ($.shouldUpdate(0, product.price, product.discount)) {
    t0 = product.price * (1 - product.discount);
    $.set(0, t0, product.price, product.discount);
  }
  
  let t1 = $.get(1);
  if ($.shouldUpdate(1, t0, product.currency)) {
    t1 = formatCurrency(t0, product.currency);
    $.set(1, t1, t0, product.currency);
  }
  
  let t2 = $.get(2);
  if ($.shouldUpdate(2, onAddToCart, product.id)) {
    t2 = () => onAddToCart(product.id);
    $.set(2, t2, onAddToCart, product.id);
  }
  
  // JSX is also cached and only recreated when inputs change
  let t3 = $.get(3);
  if ($.shouldUpdate(3, product.imageUrl, product.name, t1, t2)) {
    t3 = <div className="product-card">...</div>;
    $.set(3, t3, product.imageUrl, product.name, t1, t2);
  }
  
  return t3;
}

The practical result: you stop writing useMemo and useCallback. The compiler handles it. More importantly, it handles it correctly — the dependency tracking is static analysis based, not runtime, so it doesn’t have the stale closure bugs that manual memoization often introduces.

What the Compiler Requires

The compiler enforces React’s Rules of Hooks and the rules for pure components more strictly than the linter did. Specifically:

  1. No mutations of props or state — the compiler needs to know values are stable
  2. No side effects in render — if your component does something on every render, the compiler can’t optimize it
  3. Rules of Hooks compliance — conditional hooks will fail compilation, not just lint

If your codebase has been well-maintained, the migration is mostly automatic. If you’ve been playing fast and loose with React’s rules (and many codebases have), you’ll have some cleanup to do.

Suspense: The Server-Side Semantic Change

Suspense has always been a source of confusion because its semantics changed significantly between versions. In React 20, the change to understand is how Suspense behaves in React Server Components.

In client-side React, Suspense works as a loading boundary: when a child component throws a Promise (the Suspend signal), React shows the fallback until the Promise resolves.

In RSC with React 20, Suspense boundaries operate during server rendering, not just client rendering. This means you can stream granular pieces of your server-rendered UI:

// app/page.tsx - Server Component
export default function ProductPage({ params }) {
  return (
    <main>
      {/* This renders immediately - no data fetching */}
      <ProductHeader productId={params.id} />
      
      {/* These stream in as data becomes available */}
      <Suspense fallback={<PricingSkeleton />}>
        <ProductPricing productId={params.id} />
      </Suspense>
      
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
      
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations productId={params.id} />
      </Suspense>
    </main>
  );
}

Each Suspense boundary resolves independently. The user sees the header immediately, then pricing and reviews stream in as their respective data fetches complete — no artificial waterfall, no blocking.

The key mental model shift: Suspense in RSC is about streaming granularity, not loading states. The fallback is what users see briefly; the real work is the parallelization of data fetching.

The cache() API: Request-Scoped Memoization

Diagram showing data caching layers in a web application stack Photo by Kvistholt Photography on Unsplash

This is the most practically impactful API for server-side React code. cache() wraps a function and memoizes its result within a single server request — deduplicated across all the Server Components that call it.

// data/user.ts
import { cache } from 'react';

export const getUser = cache(async (userId: string) => {
  // This database call is deduplicated within a single request
  // If 10 components call getUser("123"), only 1 DB query runs
  const user = await db.users.findUnique({ where: { id: userId } });
  return user;
});

// Component A calls getUser(params.userId)
// Component B also calls getUser(params.userId)
// Only one DB query fires. Both get the same result.

Before cache(), the pattern for deduplication was hoisting data fetching to a parent component and prop-drilling. This worked but created coupling between components that had no business knowing about each other’s data needs.

With cache(), each component declares its own data dependencies. The runtime handles deduplication. This is the Data Colocation principle that the React team has been advocating for years, finally practical without performance penalties.

cache() vs useMemo vs React Query

These serve different purposes:

 cache()useMemoReact Query
ScopePer-request (server)Per-component instance (client)Per-query key (global client)
DurationSingle requestComponent lifetimeConfigurable (stale time)
Use caseDedup server fetchesExpensive computationsClient-side data management
InvalidationAutomatic (new request)Dependency arrayManual or time-based

Use cache() for server data fetching, useMemo for client-side computations, and React Query (or SWR) for client-side data with complex lifecycle needs.

useOptimistic: Production-Ready Now

useOptimistic shipped in React 18 as experimental. In React 20, it’s stable and the idiomatic pattern for optimistic updates.

'use client';

import { useOptimistic, useTransition } from 'react';

function CommentList({ comments, postId }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment) => [...state, { ...newComment, pending: true }]
  );
  const [isPending, startTransition] = useTransition();
  
  async function handleSubmit(formData) {
    const content = formData.get('content') as string;
    const tempComment = {
      id: crypto.randomUUID(),
      content,
      author: 'You',
      createdAt: new Date(),
    };
    
    startTransition(async () => {
      // Optimistic update happens immediately
      addOptimisticComment(tempComment);
      
      // Server action fires in background
      await submitComment(postId, content);
      // On success: real comments reload, optimistic version replaced
      // On failure: optimistic version automatically reverted
    });
  }
  
  return (
    <div>
      {optimisticComments.map(comment => (
        <Comment 
          key={comment.id} 
          comment={comment}
          opacity={comment.pending ? 0.7 : 1}
        />
      ))}
      <CommentForm onSubmit={handleSubmit} disabled={isPending} />
    </div>
  );
}

The ergonomics are much better than the manual optimistic update patterns that were common in React 17/18. The automatic rollback on failure is the killer feature — you don’t have to write error handling that manually undoes the optimistic state.

What to Upgrade Now vs. Wait On

Upgrade now:

  • Enable the React Compiler in opt-in mode on new components ("use memo" directive or compiler config for specific directories)
  • Migrate data fetching to cache() in RSC — immediate deduplication wins
  • Use useOptimistic + Server Actions for mutation UX — it’s clean and stable

Wait on (unless you’re starting fresh):

  • Full compiler migration of existing codebases — the rule enforcement will surface real issues; plan a cleanup sprint
  • Complex Suspense streaming patterns — the mental model shift is non-trivial; don’t add this to an existing page that works

React 21 is already in preview with concurrent store updates and improved animation APIs. But the React 20 APIs are stable and production-ready today. The compiler alone is worth the upgrade for any active codebase.


The official React 20 release notes and compiler playground are the best places to experiment. The new React team screencasts on their YouTube channel are worth an afternoon.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)