React 20 represents the most significant evolution of the library since hooks were introduced in React 16.8. This release brings Server Components to stable, ships a production-ready React Compiler that eliminates the need for manual memoization, introduces the use() hook for consuming promises and context, and fundamentally rethinks how React applications handle data fetching and rendering. In this article, we will explore each major feature with practical code examples so you can understand not just what changed, but why these changes matter for the applications you build.
Server Components: Rendering on the Server by Default
Server Components are the headline feature of React 20. They run exclusively on the server, have zero impact on your client-side JavaScript bundle, and can directly access databases, file systems, and backend services without building API endpoints. In React 20, components are Server Components by default:
// app/products/page.jsx
// This is a Server Component by default -- no "use server" needed
import { db } from "@/lib/database";
async function ProductList() {
// Direct database access -- no API route needed
const products = await db.query("SELECT * FROM products WHERE active = true");
return (
<section>
<h1>Our Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
<h2>{product.name}</h2>
<p>{product.description}</p>
<span>${product.price.toFixed(2)}</span>
<AddToCartButton productId={product.id} />
</li>
))}
</ul>
</section>
);
}
export default ProductList;
Notice that this is an async function component that directly queries a database. The HTML is rendered on the server and streamed to the client. The product data, the database driver, and any server-only dependencies never appear in the client bundle. Only the interactive AddToCartButton gets shipped as JavaScript to the browser.
When a component needs interactivity — event handlers, state, effects — you mark it as a Client Component with the "use client" directive:
// components/AddToCartButton.jsx
"use client";
import { useState, useTransition } from "react";
import { addToCart } from "@/actions/cart";
export function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false);
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
await addToCart(productId);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? "Adding..." : added ? "Added!" : "Add to Cart"}
</button>
);
}
The mental model is simple: keep components on the server by default and only opt into the client when you need browser APIs or interactivity. This naturally produces smaller bundles and faster initial loads.
The React Compiler: Automatic Memoization
React 20 ships a production-ready compiler (previously known as React Forget) that automatically optimizes your components. It analyzes your code at build time and inserts memoization where needed, eliminating the need to manually write useMemo, useCallback, and React.memo:
// BEFORE React 20: Manual memoization everywhere
import { useMemo, useCallback, memo } from "react";
const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
const handleClick = useCallback(
(id) => { onSelect(id); },
[onSelect]
);
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// AFTER React 20: Just write natural code
function ExpensiveList({ items, onSelect }) {
const sortedItems = [...items].sort((a, b) =>
a.name.localeCompare(b.name)
);
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
The compiler understands React’s rules (pure rendering, immutable props) and automatically determines which values need caching and which components can skip re-rendering. This removes an entire category of performance bugs caused by missing or incorrect dependency arrays.
The use() Hook: First-Class Promise and Context Support
React 20 introduces use(), a new hook that can consume promises and context. Unlike other hooks, use() can be called conditionally, which opens up patterns that were previously impossible:
"use client";
import { use, Suspense } from "react";
// use() with promises -- integrates with Suspense
function UserProfile({ userPromise }) {
const user = use(userPromise); // suspends until promise resolves
return (
<div className="profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>Member since {new Date(user.createdAt).toLocaleDateString()}</p>
</div>
);
}
// use() with context -- replaces useContext
import { ThemeContext } from "@/contexts/theme";
function ThemedButton({ children }) {
const theme = use(ThemeContext);
return (
<button
style={{
backgroundColor: theme.primary,
color: theme.text,
borderRadius: theme.borderRadius,
}}
>
{children}
</button>
);
}
// Conditional use() -- not possible with useContext
function AdminPanel({ user, adminDataPromise }) {
if (user.role !== "admin") {
return <p>Access denied</p>;
}
// use() can be called after an early return
const adminData = use(adminDataPromise);
return (
<div>
<h2>Admin Dashboard</h2>
<p>Total users: {adminData.userCount}</p>
<p>Revenue: ${adminData.revenue.toLocaleString()}</p>
</div>
);
}
// Usage with Suspense boundary
function App() {
const userPromise = fetchUser(userId);
return (
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
The use() hook works with Suspense. When you pass it an unresolved promise, the component suspends and React shows the nearest Suspense fallback. When the promise resolves, React re-renders with the data. This replaces the useEffect plus loading state pattern that has dominated React data fetching for years.
Server Actions and Form Handling
React 20 makes form handling dramatically simpler with Server Actions and the new useActionState hook. Server Actions are async functions that run on the server and can be called directly from Client Components:
// actions/newsletter.js
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email("Please enter a valid email address");
export async function subscribeToNewsletter(previousState, formData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: result.error.issues[0].message, success: false };
}
const existing = await db.query(
"SELECT id FROM subscribers WHERE email = $1",
[email]
);
if (existing.length > 0) {
return { error: "This email is already subscribed.", success: false };
}
await db.query(
"INSERT INTO subscribers (email, subscribed_at) VALUES ($1, NOW())",
[email]
);
return { error: null, success: true };
}
// components/NewsletterForm.jsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
subscribeToNewsletter,
{ error: null, success: false }
);
return (
<form action={formAction}>
<label htmlFor="email">Subscribe to our newsletter</label>
<input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Successfully subscribed!</p>}
</form>
);
}
No useState for form state, no fetch calls, no API routes. The form submits directly to the Server Action, which validates and writes to the database. The useActionState hook manages the pending state and the return value automatically. Forms even work without JavaScript enabled, progressively enhancing when JS loads.
Conclusion
React 20 is a paradigm shift that simplifies the most common patterns in web development. Server Components eliminate the client-server boilerplate for data fetching. The React Compiler removes the memoization tax that has burdened developers since hooks launched. The use() hook provides a cleaner primitive for async data and context. And Server Actions collapse the form handling stack from multiple files and abstractions down to a single function. The migration path is incremental — you can adopt these features one component at a time. Start with Server Components for your data-heavy pages, let the compiler handle optimization, and replace your useEffect data fetching with use() and Suspense. Your applications will be faster, your bundles smaller, and your code significantly easier to understand.