React 19: Compiler, Server Actions, and use()
The End of Manual Memoization
If you've spent years carefully wrapping values in useMemo, functions in useCallback, and components in React.memo, I have news: React 19's Compiler does all of that automatically -- and it does it better than you do. Correctly and comprehensively, every time.
// Before React Compiler: manual memoization everywhere
function ProductList({ products, onSelect }) {
const sorted = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
const handleClick = useCallback(
(id) => onSelect(id),
[onSelect]
);
return sorted.map(p => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
));
}
const ProductCard = memo(function ProductCard({ product, onClick }) {
return <div onClick={() => onClick(product.id)}>{product.name}</div>;
});
// After React Compiler: write the obvious code
function ProductList({ products, onSelect }) {
const sorted = [...products].sort((a, b) => a.price - b.price);
return sorted.map(p => (
<ProductCard key={p.id} product={p} onClick={() => onSelect(p.id)} />
));
}
function ProductCard({ product, onClick }) {
return <div onClick={onClick}>{product.name}</div>;
}
The Compiler analyzes your code at build time and inserts memoization automatically. It understands which values change between renders and which don't. It memoizes more granularly than humans typically do.
The Mental Model
Think of the React Compiler as an automatic transmission. Before the Compiler, React developers drove manual: you decided when to shift gears (useMemo), when to use the clutch (useCallback), and when to use cruise control (React.memo). Miss a shift and your app stutters. Over-shift and you waste effort.
The Compiler is automatic: it observes your code, understands the dependencies, and shifts for you. You focus on driving (building features), and the Compiler handles the mechanical details of when to cache and when to recompute.
But like a real automatic transmission, it requires the car to be built correctly. If your engine has side effects (impure renders), the automatic transmission can't optimize properly.
How the Compiler Works
Let's peek under the hood. The React Compiler runs as a Babel transform at build time:
// Your source code
function Price({ amount, currency }) {
const formatted = formatCurrency(amount, currency);
return <span className="price">{formatted}</span>;
}
// What the Compiler generates (simplified)
function Price({ amount, currency }) {
const $ = useMemoCache(3); // Internal: array of cached values
let formatted;
if ($[0] !== amount || $[1] !== currency) {
formatted = formatCurrency(amount, currency);
$[0] = amount;
$[1] = currency;
$[2] = formatted;
} else {
formatted = $[2];
}
// JSX is also memoized — only creates new elements when inputs change
let t0;
if ($[3] !== formatted) {
t0 = <span className="price">{formatted}</span>;
$[3] = formatted;
$[4] = t0;
} else {
t0 = $[4];
}
return t0;
}
The Compiler tracks every value's dependencies. If amount and currency haven't changed, it returns the cached formatted value and the cached JSX element. React's reconciler sees the same element reference and skips diffing the subtree.
What the Compiler can and cannot optimize
Can optimize:
- Pure computations (derived values, filtering, sorting, formatting)
- JSX element creation (prevents unnecessary reconciliation)
- Callback functions (equivalent to useCallback with correct deps)
- Component-level memoization (equivalent to React.memo)
- Hook return values (when dependency arrays would be stable)
Cannot optimize:
- Side effects in render (mutating external variables, DOM manipulation)
- Functions that read mutable global state
- Non-idempotent functions (Math.random(), Date.now() in render)
- Components using
ref.currentreads in render (refs are mutable escape hatches)
The Compiler assumes your components follow the Rules of React. If they don't, the optimizations may produce incorrect behavior. StrictMode double-rendering catches most violations.
The use() Hook
This is the hook that breaks all the rules you learned about hooks. use() is a new primitive that reads values from promises and context:
Reading Promises
// use() reads a promise — suspends the component until it resolves
function UserProfile({ userPromise }) {
const user = use(userPromise); // Suspends here if promise is pending
return <h1>{user.name}</h1>;
}
function App() {
// Create the promise at the parent level
const userPromise = fetchUser(42);
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
Unlike every other hook, use() can be called conditionally:
function UserGreeting({ userId, isLoggedIn }) {
if (isLoggedIn) {
// use() inside a conditional — this is LEGAL
const user = use(fetchUser(userId));
return <h1>Welcome back, {user.name}</h1>;
}
return <h1>Please log in</h1>;
}
Reading Context
use() also reads context, replacing useContext in new code:
function ThemeButton() {
// Old way
const theme = useContext(ThemeContext);
// New way — can be called conditionally
const theme = use(ThemeContext);
return <button style={{ background: theme.primary }}>Click</button>;
}
// Conditional context reading — not possible with useContext
function OptionalTheme({ useCustomTheme }) {
if (useCustomTheme) {
const theme = use(ThemeContext); // Only reads context when needed
return <div style={{ color: theme.text }}>Themed</div>;
}
return <div>Default</div>;
}
Server Actions
This is the one that felt like magic the first time I saw it work. Server Actions let you call server-side functions directly from client components -- no API routes, no fetch calls, no route handlers:
// app/actions.ts
'use server';
async function addToCart(productId: string) {
const session = await getSession();
await db.cart.add({ userId: session.userId, productId });
revalidatePath('/cart');
}
async function submitReview(formData: FormData) {
const rating = formData.get('rating');
const comment = formData.get('comment');
const productId = formData.get('productId');
await db.reviews.create({ productId, rating: Number(rating), comment });
revalidatePath(`/products/${productId}`);
}
// app/products/[id]/page.tsx
import { addToCart, submitReview } from '@/app/actions';
function ProductPage({ product }) {
return (
<>
{/* Server Action as onClick handler */}
<button onClick={() => addToCart(product.id)}>
Add to Cart
</button>
{/* Server Action as form action */}
<form action={submitReview}>
<input type="hidden" name="productId" value={product.id} />
<select name="rating">
<option value="5">5 stars</option>
<option value="4">4 stars</option>
</select>
<textarea name="comment" />
<button type="submit">Submit Review</button>
</form>
</>
);
}
Server Actions work without JavaScript on the client (progressive enhancement). The form submits as a standard POST request, the server processes it, and the page revalidates.
Form Actions and useActionState
React 19 adds form handling primitives:
'use client';
import { useActionState } from 'react';
import { submitOrder } from './actions';
function OrderForm() {
const [state, formAction, isPending] = useActionState(submitOrder, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="productId" required />
<input name="quantity" type="number" min="1" required />
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Order placed!</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Placing order...' : 'Place Order'}
</button>
</form>
);
}
useActionState gives you:
state: The return value from the last action invocationformAction: A wrapped version of your action to pass to<form action>isPending: Whether the action is currently executing
Server Actions are not a replacement for all API calls. They're designed for mutations (create, update, delete) and form submissions. For data fetching, use Server Components, use(), or data fetching libraries. A Server Action that just reads data and returns it works but misses the point — that's what Server Components and RSC do.
Production Scenario: Before and After React 19
This comparison really drives home how much the DX has improved. A settings page that updates user preferences:
// Before React 19: Manual everything
'use client';
function SettingsPage() {
const [settings, setSettings] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
// Manual fetch on mount
useEffect(() => {
fetch('/api/settings').then(r => r.json()).then(setSettings);
}, []);
// Manual save with loading state
const handleSave = useCallback(async (formData) => {
setSaving(true);
setError(null);
try {
const res = await fetch('/api/settings', {
method: 'PUT',
body: formData,
});
if (!res.ok) throw new Error('Save failed');
const data = await res.json();
setSettings(data);
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
}, []);
// Manual memoization
const sortedNotifications = useMemo(
() => settings?.notifications?.sort((a, b) => a.priority - b.priority),
[settings?.notifications]
);
if (!settings) return <Spinner />;
return (
<form onSubmit={e => { e.preventDefault(); handleSave(new FormData(e.target)); }}>
{error && <ErrorBanner message={error} />}
<NotificationList items={sortedNotifications} />
<button disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</form>
);
}
// After React 19: Compiler + Server Actions + use()
// app/actions.ts
'use server';
async function updateSettings(prevState, formData) {
try {
await db.settings.update(formData);
revalidatePath('/settings');
return { error: null, success: true };
} catch (e) {
return { error: e.message, success: false };
}
}
// app/settings/page.tsx (Server Component)
async function SettingsPage() {
const settings = await db.settings.get(); // Direct DB access
return (
<Suspense fallback={<Spinner />}>
<SettingsForm settings={settings} />
</Suspense>
);
}
// Client component — no manual memoization needed (Compiler handles it)
'use client';
function SettingsForm({ settings }) {
const [state, formAction, isPending] = useActionState(updateSettings, {
error: null,
success: false,
});
// No useMemo needed — Compiler auto-memoizes
const sorted = settings.notifications.sort((a, b) => a.priority - b.priority);
return (
<form action={formAction}>
{state.error && <ErrorBanner message={state.error} />}
{state.success && <SuccessBanner />}
<NotificationList items={sorted} />
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
Half the code. No useState for loading. No useEffect for fetching. No useCallback. No useMemo. No manual error handling plumbing. No API routes.
Common Mistakes
-
Wrong: Removing useMemo/useCallback before enabling the React Compiler Right: Keep existing memoization until the Compiler is configured and verified
-
Wrong: Using Server Actions for data fetching Right: Use Server Components or use() for reads. Server Actions are for mutations
-
Wrong: Calling use() with a new promise object on every render Right: Create the promise outside the rendering component or cache it
-
Wrong: Assuming the Compiler fixes impure components Right: Fix purity violations first. The Compiler assumes correct code and optimizes it
Challenge
Refactor to React 19 patterns
Quiz
Key Rules
- 1The React Compiler auto-memoizes at build time. Write plain code — no manual useMemo, useCallback, or React.memo needed (once enabled).
- 2use() reads promises (with Suspense) and context. Unlike other hooks, it can be called conditionally.
- 3Server Actions are async functions marked 'use server' that run on the server. Use for mutations, not reads.
- 4useActionState wraps Server Actions with state management: return value, form action, and pending state.
- 5The Compiler requires pure components. Side effects in render will produce incorrect cached results.
- 6Server Components fetch data directly (no useEffect). Client components handle interactivity. Draw the boundary at user interaction.