Senior 9 min · March 06, 2026

React 19 use() Hook — Fix Infinite Suspension Loop

use() hook suspends forever with no error if promise is recreated on each render.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • React 19 introduces Actions: async functions React manages for you
  • useActionState returns [state, dispatch, isPending] — replaces 3 useState calls
  • useOptimistic shows immediate UI and auto-reverts on server error
  • use() suspends components for promises and can be called conditionally
  • The React Compiler auto-inserts useMemo/useCallback at build time
  • Biggest mistake: creating promises inline with use() causes infinite suspension
Plain-English First

Imagine you're ordering pizza online. The old way: you click 'Order', the button freezes, you stare at a spinner, and you're not sure if it worked. React 19 is like a smarter pizza app — it instantly shows your order on screen before the server even confirms it, quietly handles the network call in the background, and only rolls things back if something actually goes wrong. That's the core idea: optimistic, async-first UI that doesn't make users wait for things they don't need to wait for.

React has been the dominant UI library for nearly a decade, but one thing always felt clunky: async state. Every form submission, every server request, every loading state required you to manually juggle useState, useEffect, error boundaries, and disabled buttons. For something as common as 'user submits a form', the boilerplate was embarrassing. React 19 ships in 2024 as the most significant release since Hooks in 2016, and it's laser-focused on fixing exactly that.

The problem React 19 solves is the async data lifecycle. Before 19, you needed at minimum three useState calls (data, loading, error) just to handle a single server action — and that's before you even thought about optimistic updates or race conditions. The community built libraries like React Query and SWR specifically to paper over this gap. React 19 pulls the best ideas from those libraries directly into the framework.

By the end of this article you'll understand what React Actions are and why they replace the old pattern, how useActionState and useOptimistic dramatically reduce form-handling boilerplate, what the new use() hook unlocks for async resources, how the React Compiler eliminates the need to manually write useMemo and useCallback, and when to actually reach for each feature in a production codebase.

React Actions — Async State Without the Boilerplate

An Action in React 19 is any function that wraps an async operation and hands it off to React to manage. Think of it as React saying: 'Give me the async work — I'll track whether it's pending, catch your errors, and update state when it's done.'

Before Actions, a form submission looked like this: disable the button manually, set isLoading to true, call await fetch(), set the result, catch the error, set the error state, re-enable the button. Six steps for what should be one. With Actions you pass an async function directly to a form's action prop (or to useActionState), and React handles the pending/error lifecycle automatically.

The mental model shift is important. You stop thinking about loading states as things YOU manage, and start thinking about them as things React observes for you. The useFormStatus hook is a companion piece — it lets any child component inside a form read whether the parent form's action is currently pending, without prop drilling. This is how a Submit button can disable itself without the parent form needing to pass down an isLoading prop.

ContactForm.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useActionState } from 'react';

// This is the Action — an async function React will manage for us.
// It receives the previous state and the FormData from the submission.
async function submitContactForm(previousState, formData) {
  const name = formData.get('name');
  const message = formData.get('message');

  // Simulate a real API call — could be fetch(), a server action, anything async
  const response = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, message }),
  });

  if (!response.ok) {
    // Returning an object with an error key is the convention for signaling failure
    return { error: 'Message failed to send. Please try again.' };
  }

  // Returning a success payload — this becomes the new `state` below
  return { success: true, message: `Thanks ${name}, we'll be in touch!` };
}

export default function ContactForm() {
  // useActionState wires up the action and gives us:
  //   state    — the current result (starts as null)
  //   dispatch — the function to call to trigger the action
  //   isPending — true while the async action is running
  const [state, dispatch, isPending] = useActionState(submitContactForm, null);

  return (
    // Pass dispatch as the form's action — React calls it on submit with FormData
    <form action={dispatch}>
      <label>
        Name
        <input name="name" type="text" required />
      </label>

      <label>
        Message
        <textarea name="message" required />
      </label>

      {/* Show error feedback from the action's return value */}
      {state?.error && (
        <p style={{ color: 'red' }}>{state.error}</p>
      )}

      {/* Show success feedback */}
      {state?.success && (
        <p style={{ color: 'green' }}>{state.message}</p>
      )}

      {/* isPending comes straight from React — no manual useState needed */}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}
Output
// On initial render:
// [ Name input ] [ Message textarea ] [ Send Message button ]
// While submitting (isPending = true):
// [ Name input ] [ Message textarea ] [ Sending... button (disabled) ]
// On success (state = { success: true, message: "Thanks Alex..." }):
// [ Name input ] [ Message textarea ]
// "Thanks Alex, we'll be in touch!" (green)
// [ Send Message button ]
// On error (state = { error: "Message failed to send..." }):
// [ Name input ] [ Message textarea ]
// "Message failed to send. Please try again." (red)
// [ Send Message button ]
Why useActionState replaces useState + useEffect for forms
useActionState is NOT just useState with a loading flag bolted on. It resets correctly between concurrent renders, is compatible with React Server Components, and its isPending flag is driven by React's scheduler — making it safe in Strict Mode and concurrent features where manual booleans can get out of sync.
Production Insight
useActionState's isPending flag is driven by React's concurrent scheduler — it stays accurate even across state updates and transitions.
But if your action function doesn't return a promise (e.g., returns undefined), isPending may never become false.
Always return a promise from action functions.
If you need to reset state to initial, you can pass a new dispatch or use the key prop on the form.
Rule: Action functions must return a promise — otherwise isPending stays true forever.
Key Takeaway
useActionState replaces 3x useState boilerplate for async forms
React manages pending/error lifecycle automatically
Action function receives previous state and FormData
Rule: Always return an object with error or success keys for clean feedback.
When to use useActionState vs manual state
IfForm submission with loading/error/success feedback
UseUse useActionState — cuts boilerplate by 3x
IfYou need to call the async function from multiple places (not just form submit)
UseUse useActionState with manual dispatch — but ensure you pass FormData
IfYou need the loading state in a child component
UsePair with useFormStatus instead of passing isPending as prop

React 19 Actions API Quick Reference Table

React 19 consolidates several new APIs around the concept of Actions. Below is a quick reference table for the most commonly used functions, their purpose, and usage pattern.

APIPurposeKey SignatureTypical Use Case
useActionStateManage async form state with pending, success, error[state, dispatch, isPending] = useActionState(action, initialState)Form submissions with loading feedback and result display
useFormStatusRead pending state from nearest <form> action{ pending, data, method, action } = useFormStatus()Child components that need to disable themselves during submission
useOptimisticShow immediate optimistic UI that auto-rolls back on error[optimisticState, setOptimistic] = useOptimistic(realState, updateFn)Like buttons, add to cart, instant toggles
<form action>Pass a function as form action to enable Actions<form action={dispatch}>Replaces manual onSubmit with automatic FormData and isPending tracking
startTransitionMark a non-urgent update as a transitionstartTransition(() => { setState(newValue) })Wrapping async operations that should not block urgent input
useTransitionGet isPending for a transition[isPending, startTransition] = useTransition()Showing loading state for slow state updates
Server ActionsDefine actions that run on the server, called from client components (with "use server" directive)"use server"; export async function myAction(data) {}Directly calling server logic without building an API route

The key distinction: useActionState is for managing state from an action's return, useFormStatus is for reading the action's pending status from any child, and useOptimistic is for giving the user instant feedback. Server Actions skip the client bundle entirely but can be called via <form action> or startTransition.

actions-reference.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Quick signatures for each API

// 1. useActionState
const [state, dispatch, isPending] = useActionState(
  async (prevState, formData) => {
    // ... handle submission
    return { success: true };
  },
  null
);

// 2. useFormStatus (in a child component)
function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}

// 3. useOptimistic
const [optimisticCount, setOptimisticCount] = useOptimistic(
  realCount,
  (current, newVal) => current + 1
);

// 4. form action
<form action={dispatch}> // dispatch triggers the action with FormData

// 5. startTransition
import { startTransition } from 'react';
startTransition(async () => {
  const result = await serverAction();
  setState(result);
});

// 6. Server Action (in a file with "use server")
"use server";
export async function addToCart(productId) {
  // runs on server, receives serialized data
  await db.products.add(userId, productId);
}
Remember: useActionState returns [state, dispatch, isPending]
The dispatch function replaces both the form's onSubmit handler and the need to call e.preventDefault(). React automatically receives the FormData object and passes it as the second argument to your action function. You can also call dispatch manually with a FormData instance if you need to trigger the action outside a form.
Production Insight
In production, Server Actions (with "use server") compile to separate endpoints that the client invokes via fetch. This means they work even without a full server implementation like Next.js, though you typically pair them with a framework that handles routing. The same action can be used both from a form action and from startTransition, giving you flexibility in how you trigger server-side logic.
Rule: Always validate and sanitize input inside Server Actions — they are public endpoints.
Key Takeaway
React 19 Actions unify async data handling across forms, transitions, and server calls. Know which API to reach for: useActionState for form state, useFormStatus for child pending, useOptimistic for instant feedback, and Server Actions for direct server logic.

useOptimistic — Show the Answer Before the Server Replies

useOptimistic solves a specific, painful UX problem: the gap between a user taking an action and the server confirming it. A like button that takes 400ms to respond feels broken. A todo item that doesn't appear until after a round-trip feels slow. Users in 2024 expect instant feedback.

The hook takes two arguments: the current real state, and an updater function that describes how to compute the optimistic version. When you call setOptimisticValue inside an async action, React immediately shows the optimistic UI. When the action resolves (or rejects), React automatically reverts to whatever the server sent back.

The key insight is that useOptimistic is scoped to the duration of an async transition. You don't manually clean it up. React handles rollback automatically if the server returns an error. This means you get the snappy feel of a local-first app without having to build a full offline-sync system.

LikeButton.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { useOptimistic, useState } from 'react';

// Simulated API call — imagine this is hitting your backend
async function toggleLikeOnServer(postId, currentlyLiked) {
  await new Promise(resolve => setTimeout(resolve, 600)); // Simulate latency

  // In a real app this would be: await fetch(`/api/posts/${postId}/like`, ...)
  // For demo purposes, we just return the toggled value
  return !currentlyLiked;
}

export default function LikeButton({ postId, initialLikeCount, initiallyLiked }) {
  // This is the REAL state — what the server has confirmed
  const [isLiked, setIsLiked] = useState(initiallyLiked);
  const [likeCount, setLikeCount] = useState(initialLikeCount);

  // useOptimistic takes: real value, and a function to compute the optimistic value
  // The second arg receives (currentState, optimisticValue) — the optimisticValue
  // is whatever you pass to setOptimisticLiked() below
  const [optimisticIsLiked, setOptimisticLiked] = useOptimistic(
    isLiked,
    (currentIsLiked, newLikedValue) => newLikedValue // Simply replace with the optimistic value
  );

  async function handleLikeToggle() {
    const nextLikedValue = !isLiked;

    // This IMMEDIATELY updates the UI — no waiting for the server
    setOptimisticLiked(nextLikedValue);

    // Also update the count optimistically in the UI
    setLikeCount(prev => nextLikedValue ? prev + 1 : prev - 1);

    try {
      // Now do the real work — React keeps the optimistic UI until this resolves
      const confirmedValue = await toggleLikeOnServer(postId, isLiked);

      // Commit the real server-confirmed value to state
      setIsLiked(confirmedValue);
    } catch (error) {
      // If the server call fails, React automatically reverts optimisticIsLiked
      // We also need to roll back our manual likeCount update
      setLikeCount(prev => nextLikedValue ? prev - 1 : prev + 1);
      console.error('Failed to update like status:', error);
    }
  }

  return (
    <button
      onClick={handleLikeToggle}
      aria-label={optimisticIsLiked ? 'Unlike post' : 'Like post'}
      style={{
        background: optimisticIsLiked ? '#e0245e' : '#ccc',
        color: 'white',
        border: 'none',
        padding: '8px 16px',
        borderRadius: '20px',
        cursor: 'pointer',
      }}
    >
      {/* Use the OPTIMISTIC value for instant visual feedback */}
      {optimisticIsLiked ? '❤️' : '🤍'} {likeCount}
    </button>
  );
}
Output
// Initial render (isLiked=false, count=42):
// [ 🤍 42 ] (grey button)
// Immediately after click — BEFORE server responds:
// [ ❤️ 43 ] (red button) — optimistic update
// 600ms later, server confirms — state stays the same:
// [ ❤️ 43 ] (red button) — now backed by real state
// If server throws an error:
// [ 🤍 42 ] (grey button) — automatically reverted
Watch Out: useOptimistic only works inside async transitions
The optimistic update only stays visible while an async action is in progress. If you call setOptimisticLiked() outside of an async action (e.g., in a plain synchronous event handler), the optimistic value disappears on the very next render. Always pair it with an async function or wrap in startTransition.
Production Insight
If you call setOptimisticLiked outside an async context (e.g., in a plain click handler), the optimistic value reverts on the very next render — React treats it as stale because no transition is in progress.
Always pair with an async action or startTransition.
Also, the optimistic updater function receives the current state and the call payload. Keep it pure — don't mutate the current state.
Rule: Optimistic updates are only stable within async transitions.
Key Takeaway
useOptimistic shows instant UI before server confirms
Auto-reverts on error — no manual rollback
Works within async actions or startTransition
Rule: Keep the updater function pure and scoped to transition duration.
When to use useOptimistic
IfUser action must show feedback instantly (like/dislike, add to cart)
UseUse useOptimistic — provides instant UI, auto-revert on error
IfYou can wait for server confirmation before showing any update
UseStick with regular useState + useEffect — optimistic adds complexity
IfNeed to optimistically update multiple pieces of state (like count and liked state)
UseUse useOptimistic for the boolean, manually update count with separate setter

The new use() Hook — Reading Resources Inline

use() is unlike any hook before it. It can be called conditionally (breaking the rules of hooks), it can be called inside loops, and it can unwrap both Promises and Context objects. It's React saying: 'I'll suspend this component for you while this promise resolves — no useEffect, no useState, no manual async lifecycle.'

For Context, use(MyContext) replaces useContext(MyContext) and works identically — except you can now call it inside an if-block, which lets you bail out of a context read early without restructuring your component.

For Promises, use() integrates with Suspense. You pass a promise directly to use(), and the component suspends (shows the nearest Suspense fallback) until the promise resolves. This is the client-side complement to React Server Components' async/await. The critical caveat: don't create the promise inside the render function — create it outside or via a cache/memo, or you'll create a new promise every render and suspend forever.

UserProfile.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { use, Suspense, createContext } from 'react';

// --- Context example: use() with conditional reads ---
const ThemeContext = createContext('light');

function ThemedBadge({ isAdmin }) {
  // This was IMPOSSIBLE with useContext — you couldn't call it conditionally
  // Now with use(), we can read context only when we actually need it
  if (!isAdmin) {
    return <span className="badge">Member</span>;
  }

  // Called conditionally — totally valid with use()
  const theme = use(ThemeContext);

  return (
    <span className={`badge badge--${theme} badge--admin`}>
      Admin
    </span>
  );
}

// --- Promise example: use() with Suspense ---

// IMPORTANT: Create the promise OUTSIDE the component.
// If you write `const userPromise = fetchUser()` inside UserCard,
// it creates a NEW promise every render → infinite suspension loop.
const userDataPromise = fetchUserFromAPI(42); // Created once, at module level

function UserCard() {
  // use() suspends this component until the promise resolves.
  // No useState, no useEffect, no loading check needed here.
  const user = use(userDataPromise);

  return (
    <div className="user-card">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// The parent wraps with Suspense to handle the loading state
export default function ProfilePage() {
  return (
    <ThemeContext.Provider value="dark">
      <ThemedBadge isAdmin={true} />

      {/* Suspense shows the fallback while UserCard's promise resolves */}
      <Suspense fallback={
        <div className="skeleton" aria-busy="true">Loading profile...</div>
      }>
        <UserCard />
      </Suspense>
    </ThemeContext.Provider>
  );
}

// Simulated fetch — in real life this is your API call
async function fetchUserFromAPI(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) throw new Error(`User ${userId} not found`);
  return response.json();
  // Returns: { id: 42, name: 'Sarah Chen', email: 'sarah@example.com' }
}
Output
// While the promise is pending (component is suspended):
// <div class="skeleton" aria-busy="true">Loading profile...</div>
// (The Suspense fallback is shown by the nearest Suspense boundary)
// After the promise resolves:
// <span class="badge badge--dark badge--admin">Admin</span>
// <div class="user-card">
// <h2>Sarah Chen</h2>
// <p>sarah@example.com</p>
// </div>
// If the promise rejects — caught by the nearest ErrorBoundary
Pro Tip: Pair use() with React Query or a cache utility
use() doesn't deduplicate or cache promises — it just reads them. If two components use() the same promise, they'll both fire it. Use React Query's queryClient.fetchQuery(), or a simple module-level cache (like a Map keyed by resource ID), to ensure the promise is created once and shared. This is how you avoid double-fetching in production.
Production Insight
The biggest production gotcha: creating promises inside the render function. Each render creates a new promise → use() suspends → React commits the Suspense fallback → retries render → new promise → infinite loop.
This can take down part of your UI silently. Always hoist promise creation or use a stable cache.
For context reading, use() offers conditional access — but if you call it conditionally and later in the same render a sibling also calls use() on the same context, it's fine.
Rule: Stable promise references are non-negotiable for use().
Key Takeaway
use() is the first hook that can be called conditionally
Can read both promises (with Suspense) and Context
Never create promises inside render — create at module level or use cache
Rule: use(MyContext) replaces useContext with conditional superpower.
When to use use() vs useEffect
IfYou need to conditionally read context based on a prop
UseUse use(MyContext) — the only way to conditionally read context
IfYou want to suspend a component while a promise resolves
UseUse use(promise) with Suspense — cleaner than loading + useEffect
IfYou need to deduplicate requests for the same resource
UseUse React Query or SWR — use() alone doesn't cache or deduplicate

Ref as Prop vs forwardRef in React 19

One of the quality-of-life improvements in React 19 is that you can now pass ref as a regular prop to a function component without wrapping it in forwardRef. This eliminates a long-standing inconsistency where function components needed special treatment to receive refs, while class components did not.

Before React 19, if you wanted a parent component to access the DOM node of a child function component, you had to use forwardRef: ``jsx const Child = forwardRef((props, ref) => { return <input ref={ref} />; }); ` This added boilerplate and was confusing — many developers expected ref` to simply work as a prop.

In React 19, ref is treated just like any other prop. If you name a prop ref on a function component, React automatically forwards it to the underlying DOM element (or to the inner component). However, this only works if you explicitly accept ref as a prop. The old forwardRef API still works and is necessary for class components.

The key rule: if your component is a function component, you can simply accept ref as a prop. If it's a class component, you must still use forwardRef because class instances are not directly assignable to refs in the same way.

This change reduces code and makes component APIs more intuitive. It also aligns with the pattern of treating ref like key — a special prop that React handles.

RefComparison.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// ─── BEFORE REACT 19 (still works in 19, but unnecessary) ───
import { forwardRef, useRef, useEffect } from 'react';

const OldStyledInput = forwardRef(function OldStyledInput(props, ref) {
  // forwardRef adds an extra layer of function signature
  return (
    <input
      ref={ref}
      style={{ border: '1px solid blue' }}
      {...props}
    />
  );
});

function OldParent() {
  const inputRef = useRef(null);
  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }, []);
  return <OldStyledInput ref={inputRef} placeholder="Old way" />;
}

// ─── IN REACT 19: ref as a regular prop ───
// No forwardRef needed! Just name a prop 'ref' and it works.
function NewStyledInput({ ref, ...props }) {
  return (
    <input
      ref={ref}
      style={{ border: '2px solid green' }}
      {...props}
    />
  );
}

function NewParent() {
  const inputRef = useRef(null);
  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }, []);
  return <NewStyledInput ref={inputRef} placeholder="New way" />;
}

// Both produce identical behavior: the parent obtains a ref to the input element.
// The new approach is clearer and closer to how developers expect refs to work.

// ⚠️ For class components, you still need forwardRef:
class ClassInput extends React.Component {
  render() {
    return <input ref={this.props.ref} />; // This alone does NOT forward
  }
}
// Must wrap: const ClassInputForwarded = forwardRef((props, ref) => <ClassInput {...props} ref={ref} />);
forwardRef is still useful for class components and wrapping libraries
The forwardRef API is not deprecated. Use it when: you need to forward a ref through a class component (class components can't accept ref as a prop), you're building a component library that needs to support both function and class components, or you want to be explicit about ref forwarding for documentation clarity.
Production Insight
In production, if you migrate existing code from forwardRef to ref-as-prop, ensure you don't break components that still use forwardRef. You can safely remove forwardRef from function components, but keep it for class components. The behavior is identical, but the code is cleaner.
Rule: Always rename the second parameter ref when using forwardRef; when not using forwardRef, destructure ref from props directly.
Key Takeaway
React 19 allows passing ref as a regular prop to function components, eliminating the need for forwardRef in many cases. This reduces boilerplate and makes component signatures more intuitive. forwardRef is still required for class components.

The React Compiler — Automatic Memoization Without the Mental Overhead

The React Compiler (previously 'React Forget') is an opt-in build-time tool that automatically inserts useMemo, useCallback, and React.memo optimizations into your code. You write plain, readable JavaScript — the compiler figures out what's referentially stable and what needs memoizing.

Why does this matter? Because manual memoization is one of the most error-prone parts of React development. Developers either over-memoize (wrapping everything in useMemo when it's unnecessary and making code harder to read) or under-memoize (missing the one place that's causing expensive re-renders). The compiler eliminates that entire class of bugs.

The compiler ships as a Babel/SWC plugin in React 19 and is already running on instagram.com in production. It works by analyzing your component's data dependencies at compile time. If a value genuinely can't change between renders given the same props/state, the compiler memoizes it automatically. If it can change, the compiler leaves it reactive. The result: you get the performance of a hand-optimized component with none of the cognitive overhead.

ProductList.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// BEFORE the React Compiler — what you had to write manually
import { useMemo, useCallback, memo } from 'react';

const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
  // memo() prevents re-renders when parent re-renders but props haven't changed
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});

function ProductListBefore({ products, taxRate, userId }) {
  // useMemo to avoid recalculating prices on every render
  const productsWithTax = useMemo(() => {
    return products.map(p => ({
      ...p,
      price: (p.basePrice * (1 + taxRate)).toFixed(2),
    }));
  }, [products, taxRate]); // Easy to forget a dependency here

  // useCallback to keep the function reference stable for memo(ProductCard)
  const handleAddToCart = useCallback((productId) => {
    fetch(`/api/cart/${userId}/add`, {
      method: 'POST',
      body: JSON.stringify({ productId }),
    });
  }, [userId]); // If you forget userId here, you get a stale closure bug

  return productsWithTax.map(product => (
    <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
  ));
}

// ─────────────────────────────────────────────────────
// AFTER the React Compiler — what you write instead
// The compiler transforms this into the equivalent of the code above
import { useState } from 'react';

function ProductCard({ product, onAddToCart }) {
  // No memo() wrapper needed — compiler handles it
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
}

function ProductListAfter({ products, taxRate, userId }) {
  // No useMemo — compiler sees taxRate and products are dependencies
  // and automatically memoizes this calculation
  const productsWithTax = products.map(p => ({
    ...p,
    price: (p.basePrice * (1 + taxRate)).toFixed(2),
  }));

  // No useCallback — compiler tracks that userId is the only dependency
  // and keeps the function reference stable automatically
  function handleAddToCart(productId) {
    fetch(`/api/cart/${userId}/add`, {
      method: 'POST',
      body: JSON.stringify({ productId }),
    });
  }

  return productsWithTax.map(product => (
    <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
  ));
}

// Both versions produce the SAME runtime behavior and performance.
// The compiler version is just dramatically easier to read and maintain.
Output
// Both components render identically:
// <div class="product-card">
// <h3>Mechanical Keyboard</h3>
// <p>$109.89</p> ← basePrice $99 * (1 + 0.11 tax rate)
// <button>Add to Cart</button>
// </div>
// With the compiler: ProductCard does NOT re-render when
// an unrelated piece of parent state changes — same as memo().
// But you wrote zero memoization code yourself.
The Compiler Requires 'Rules of Hooks' Compliance
The React Compiler can only safely optimize components that follow the Rules of Hooks and treat state/props as immutable. If your component mutates props directly (e.g., props.items.push(...)) or has side effects inside render, the compiler will skip optimizing that component and leave it as-is. Run eslint-plugin-react-compiler to catch these cases before enabling the compiler in production.
Production Insight
The compiler runs at build time and transforms code. It cannot optimize components that mutate props/state or break the Rules of Hooks.
In a large codebase, expect ~20-40% of components to be skipped on first pass.
Run eslint-plugin-react-compiler to identify and fix these cases before enabling the compiler in CI.
The compiler also respects existing useMemo/useCallback — if you already wrote them, the compiler keeps them. No double optimization issue.
Rule: Run the health check before adopting the compiler at scale.
Key Takeaway
React Compiler auto-inserts memoization at build time
No runtime change — same behavior as hand-optimized code
Skips components that mutate state or break hook rules
Rule: Clean immutable code is prerequisite for compiler optimization.

useFormStatus — Prop Drilling No More

useFormStatus is a companion to Actions that solves a long-standing pain point: reading a form's pending state inside a deeply nested child component without prop drilling. Before, if you had a Submit button nested inside a complex form with multiple field components, you'd have to pass an isLoading prop all the way down. useFormStatus lets any component inside a <form> read the form's pending status directly.

The hook returns { pending, data, method, action }. The pending flag is true when the closest ancestor <form>'s action is running. This is especially powerful with design systems where you want a reusable SubmitButton component that disables itself automatically based on the form's state — no prop drilling, no context wrappers.

Critically, useFormStatus only works inside a <form> that uses React Actions via the action prop. If you're handling submissions manually with onSubmit, the hook won't see any pending state.

SubmitButton.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useFormStatus } from 'react-dom';

export default function SubmitButton({ children }) {
  // This works because the button is inside a <form> with action={dispatch}
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : children}
    </button>
  );
}

// Usage in a parent form:
// <form action={dispatch}>
//   <input name="email" />
//   <SubmitButton>Send</SubmitButton>
// </form>
Output
// Form idle:
// [ Email input ] [ Send button ]
// Form submitting:
// [ Email input ] [ Submitting... button (disabled) ]
// Form succeeded:
// [ Email input ] [ Send button ]
// Note: The SubmitButton has no props passed to it.
// It reads pending state directly from the nearest <form>.
useFormStatus requires a form with an action prop
It reads the status from the nearest ancestor <form> element. If you use onSubmit instead of action, pending will always be false. This is a common rookie mistake — make sure your form uses the action prop to unlock useFormStatus.
Production Insight
useFormStatus is one of the few hooks that breaks the 'rules of hooks' in a controlled way — it reads from a form context.
If your button renders outside the <form> tag, pending stays false silently.
Always verify the component is a descendant of a <form> element.
In design system components, accept an optional formId prop and ensure the button is rendered inside that form using a portal or careful layout.
Rule: useFormStatus only works when the component is a descendant of a <form> with an action prop.
Key Takeaway
useFormStatus eliminates prop drilling for pending state
Child components can disable themselves based on form action status
But only works with the action prop — not onSubmit
Rule: use <form action> to unlock any component inside it.

Server Components vs Client Components in React 19

React 19 formalizes the distinction between Server Components and Client Components through the 'use client' and 'use server' directives. Understanding when to use each is critical for performance and correct behavior.

Server Components run exclusively on the server during rendering. They can directly access databases, file systems, and backend APIs without exposing that logic to the client. They cannot use state, effects, or browser APIs. They produce serializable output that is sent as HTML or streaming chunks to the client. Server Components reduce bundle size because their code never reaches the browser.

Client Components are the traditional React components that run in the browser. They have full access to hooks (useState, useEffect, etc.), event handlers, and browser APIs. In React 19, any component tree that doesn't start with 'use client' is assumed to be a server component. To make a component a client component, add 'use client' as the first line of the file.

The key rule: you can import a Server Component inside a Client Component, but the Server Component will only render its initial output on the server; any dynamic behavior must be handled by the parent Client Component. Conversely, a Server Component can import both server and client components, but the client components will still execute on the client.

AspectServer ComponentClient Component
Renders onServerClient (browser)
Can use state/effectsNo (must be async or pure)Yes
Access database/backendDirectly (in file)Via API calls (fetch)
Bundle size0 bytes sent to clientFull component code sent
InteractiveNo (can be parent of client components)Yes
DirectiveNone (default)'use client' at top of file
Typical useData fetching, content renderingForm interactions, UI state, animation

React 19 introduces Server Actions as well — functions marked with 'use server' that can be called from client components. They run on the server and can be used for mutations without building a separate API endpoint.

ServerClientExample.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// ─── Server Component (no directive) ───
// This file runs on the server only.
// It can directly query a database.
import { getPosts } from '@/lib/db';
import LikeButton from './LikeButton'; // client component

export default async function PostList() {
  const posts = await getPosts(); // direct DB access
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
          <LikeButton postId={post.id} /> {/* client component with interactivity */}
        </li>
      ))}
    </ul>
  );
}

// ─── Client Component (with 'use client') ───
// This file runs in the browser.
'use client';

import { useState } from 'react';

export default function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

// ─── Server Action (in a file with 'use server') ───
'use server';

export async function addLike(postId) {
  // runs on server, can directly mutate DB
  const db = await connect();
  await db.likes.insert({ postId, timestamp: new Date() });
}
RSC is not CRA or Vite — it requires a server runtime
Server Components and Server Actions require a framework that implements the React Server Components protocol, such as Next.js, Remix (future), or custom Node.js setup. They do not work in a pure client-side build like Create React App or Vite without additional server infrastructure. For client-only apps, all components are implicitly Client Components.
Production Insight
Migrating to Server Components can dramatically reduce bundle size. A typical page might drop from 200KB to 50KB of JS because server components' code never ships to the client. However, be careful: if you import a client component from a server component, that client component's code still ships. Server Actions reduce API boilerplate but must be used only in server environments. For data fetching, prefer Server Components for initial page load and Client Components for subsequent interactive updates.
Rule: Use Server Components for static data and initial render, Client Components for interactivity and state.
Key Takeaway
Server Components run on the server, access data directly, and ship zero JS. Client Components run in the browser, use hooks and events. The 'use client' directive marks the boundary. Server Actions allow server-side mutations from client code. Use them to optimize bundle size and simplify data fetching.
● Production incidentPOST-MORTEMseverity: high

Infinite Suspension Loop with use()

Symptom
Component shows loading fallback indefinitely, never resolves, no console errors.
Assumption
The developer assumed use() would cache the promise automatically.
Root cause
use() stores the promise in a fiber but does not deduplicate. Each render creates a new Promise instance, so use() sees a new pending promise each time and suspends again.
Fix
Move the promise creation outside the component or use a stable cache like React Query's fetchQuery or a useRef-based memoization.
Key lesson
  • Always create promises at module level or in a stable location.
  • Never create inside render when passing to use().
  • Use React Query or a cache utility to deduplicate promise creation.
Production debug guideCommon symptoms and actions for Actions, useOptimistic, and use()4 entries
Symptom · 01
Button remains disabled after form submission succeeds
Fix
Check if isPending is stuck. Ensure the async action resolves or rejects. Use a timeout wrapper to detect hanging promises.
Symptom · 02
Optimistic UI does not revert on server error
Fix
Verify setOptimisticLiked is called inside an async transition. It only reverts automatically if called within an async action.
Symptom · 03
use() causes component to suspend forever
Fix
Check if the promise is created inside the render function. Move it to module level or use a stable reference.
Symptom · 04
useActionState works in dev mode but not in production
Fix
Check for minification issues with function names. Ensure the action function is not inlined in a way that loses reference.
★ Quick Debug Cheat Sheet for React 19 HooksUse this when things go wrong in development or production. Each row gives you the symptom, what to do immediately, the right commands to run, and the fix.
Form does not submit
Immediate action
Check that dispatch is passed to <form action={dispatch}>
Commands
Add console.log('Form submitted') at top of action function
Check if form inputs have name attributes
Fix now
Add name to each input or manually construct FormData
Optimistic UI shows but never commits+
Immediate action
Check if async function is awaited
Commands
Wrap async action in startTransition
Add error boundary to catch rejections
Fix now
Call setOptimisticLiked within an async function that calls setLiked with server result
use() suspends indefinitely+
Immediate action
Check where promise is created
Commands
Log promise instance: console.log(promise === previousPromise)
Use React Query's queryClient.fetchQuery() to deduplicate
Fix now
Create promise once outside component using useRef or module-level const
FeatureReact 18 ApproachReact 19 Approach
Form submission state3x useState (data, loading, error) + manual managementuseActionState — single hook returns [state, dispatch, isPending]
Optimistic UIManual rollback logic, error handling, state jugglinguseOptimistic — auto-reverts on error, scoped to async transition
Reading async datauseEffect + useState + loading guard in JSXuse(promise) inside Suspense boundary — component suspends cleanly
Reading Context conditionallyImpossible — useContext can't be called conditionallyuse(MyContext) — callable inside if-blocks and loops
Performance optimizationManual useMemo, useCallback, React.memo — easy to get wrongReact Compiler — automated at build time, zero runtime cost
Pending state in child componentsPass isLoading prop down (prop drilling)useFormStatus() — any child reads pending state from nearest form Action

Key takeaways

1
React Actions (useActionState) collapse the three-useState pattern for async forms into a single hook
state, dispatch, and isPending are all returned together, and React manages the entire pending/error lifecycle for you.
2
useOptimistic is scoped to async transitions
the optimistic value is ONLY visible while the async action is in progress, and React automatically reverts it on error, meaning you never have to write rollback logic manually.
3
use() is the first hook that can be called conditionally, but it requires promises to be stable references
create them outside the component or via a cache, never inline inside the render function.
4
The React Compiler doesn't change how React works at runtime
it's a build step that emits the useMemo/useCallback code you should have written anyway, so you get the performance benefits of manual memoization from clean, unmemoized source code.
5
useFormStatus lets any child component inside a form read the form's pending state without prop drilling
but only works when the form uses the action prop, not onSubmit.

Common mistakes to avoid

3 patterns
×

Creating promise inside component for use()

Symptom
Every render creates a new Promise object, so use() suspends again on each retry, leading to infinite loading with no error.
Fix
Declare the promise outside the component at module level, or use a stable cache like useRef or React Query to ensure the same Promise instance is returned on every render.
×

Calling useActionState dispatch from a non-form event

Symptom
dispatch(event) passes a SyntheticEvent instead of FormData. The action receives an event object and formData.get() returns null silently.
Fix
Use useActionState with a <form action={dispatch}> so React automatically extracts FormData, OR manually construct a FormData object and call dispatch(myFormData) if you need a non-form trigger.
×

Mutating state before enabling React Compiler

Symptom
The compiler assumes immutability; mutations like items.push(newItem) can cause stale cache and UI stops updating.
Fix
Always use the setter function (setItems([...items, newItem])) and run npx react-compiler-healthcheck on your codebase before opting in to the compiler.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What problem does useActionState solve that couldn't be solved with useS...
Q02SENIOR
How does useOptimistic differ from simply updating local state immediate...
Q03SENIOR
The new use() hook can be called conditionally — but what critical rule ...
Q04SENIOR
Explain how the React Compiler eliminates the need for manual useMemo an...
Q01 of 04SENIOR

What problem does useActionState solve that couldn't be solved with useState and useEffect, and what are the three values it returns?

ANSWER
useActionState solves the common pattern of managing loading, data, and error states manually. It returns [state, dispatch, isPending]. State holds the action result, dispatch triggers the action, and isPending is managed by React's scheduler — safe in concurrent mode.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Is React 19 backward compatible with React 18 code?
02
Do I need to use React Server Components to benefit from React 19's new features?
03
Does the React Compiler replace useMemo and useCallback entirely — should I stop writing them?
🔥

That's React.js. Mark it forged?

9 min read · try the examples if you haven't

Previous
React Server Components
19 / 47 · React.js
Next
Next.js 16: Every New Feature Explained with Code Examples