Skip to content

React 19: Compiler, Server Actions, and use()

advanced13 min read

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

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.current reads 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>;
}
Execution Trace
Render:
use(promise) called
Check: is the promise resolved?
Pending:
Promise not resolved
Throw the promise. Suspense boundary shows fallback
Resolve:
Promise resolves with data
React schedules re-render of the Suspense subtree
Re-render:
use(promise) called again
Promise is resolved → returns the value directly
Complete:
Component renders with data
Suspense fallback replaced with actual content

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 invocation
  • formAction: A wrapped version of your action to pass to <form action>
  • isPending: Whether the action is currently executing
Common Trap

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

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

Quiz
What does the React Compiler do at build time?

Key Rules

Key Rules
  1. 1The React Compiler auto-memoizes at build time. Write plain code — no manual useMemo, useCallback, or React.memo needed (once enabled).
  2. 2use() reads promises (with Suspense) and context. Unlike other hooks, it can be called conditionally.
  3. 3Server Actions are async functions marked 'use server' that run on the server. Use for mutations, not reads.
  4. 4useActionState wraps Server Actions with state management: return value, form action, and pending state.
  5. 5The Compiler requires pure components. Side effects in render will produce incorrect cached results.
  6. 6Server Components fetch data directly (no useEffect). Client components handle interactivity. Draw the boundary at user interaction.