Chapter 28

React 19 Complete: Actions, use(), Compiler and Concurrent Improvements

React 19 is the most significant release since React 18's concurrent mode. It not only refines concurrent features but introduces the server-oriented Actions model, the revolutionary use() Hook, an automatic memoization compiler, and a series of small but meaningful developer experience improvements. This chapter dives deep into the design motivation and implementation principles of each feature.

Actions: A Declarative Model for Async Operations

The Problem: Form Submission State Management Nightmare

In the React 18 era, handling a single async form submission required manually managing a substantial amount of state:

// Typical async form handling in React 18
function UpdateNameForm() {
  const [name, setName] = useState('');
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);
  const [optimisticName, setOptimisticName] = useState('');
  
  async function handleSubmit(e) {
    e.preventDefault();
    setIsPending(true);
    setError(null);
    setOptimisticName(name); // optimistic update
    
    try {
      await updateName(name);
    } catch (err) {
      setError(err.message);
      setOptimisticName(''); // roll back optimistic update
    } finally {
      setIsPending(false);
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      {isPending && <Spinner />}
      {error && <p>{error}</p>}
    </form>
  );
}

The code is not overly complex, but repeating this pattern across every form is tedious, and it is easy to miss error handling in one place.

Actions: Async Functions as Form Handlers

React 19 introduces the Actions concept: you can pass an async function directly to a form's action attribute, and React automatically manages the pending state, error state, and optimistic updates:

// The Actions model in React 19
function UpdateNameForm() {
  const [error, submitAction, isPending] = useActionState(
    async (prevState, formData) => {
      const name = formData.get('name');
      const error = await updateName(name);
      if (error) return error;
      return null;
    },
    null // initial state
  );
  
  return (
    <form action={submitAction}>
      <input name="name" />
      {isPending && <Spinner />}
      {error && <p>{error}</p>}
      <button type="submit" disabled={isPending}>Update</button>
    </form>
  );
}

useActionState accepts an async function and returns a [state, action, isPending] tuple. When the form is submitted, React automatically sets isPending to true, waits for the async function to complete, updates the state, then resets isPending to false.

useFormStatus: Reading Parent Form State

useFormStatus allows child components to read the submission status of their enclosing form without prop drilling:

// Backed by packages/react-dom/src/client/ReactDOMFormElement.js
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  // Automatically reads the nearest parent <form>'s status
  const { pending, data, method, action } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

function Form() {
  return (
    <form action={someAction}>
      <input name="data" />
      <SubmitButton /> {/* Automatically gets the form's pending state */}
    </form>
  );
}

useFormStatus uses React's Context mechanism under the hood. When React processes <form action={fn}>, it creates a FormStatus Context that all descendant components can subscribe to via useFormStatus.

useOptimistic: Declarative Optimistic Updates

import { useOptimistic } from 'react';

function MessageList({ messages, sendMessage }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (currentMessages, newMessage) => [
      ...currentMessages,
      { text: newMessage, status: 'sending' }
    ]
  );
  
  async function formAction(formData) {
    const message = formData.get('message');
    // Immediately show the optimistic message (without waiting for server response)
    addOptimisticMessage(message);
    // Send to server
    await sendMessage(message);
    // After server responds, optimisticMessages automatically reverts to messages (real data)
  }
  
  return (
    <div>
      {optimisticMessages.map((msg, i) => (
        <p key={i} style={{ opacity: msg.status === 'sending' ? 0.7 : 1 }}>
          {msg.text}
        </p>
      ))}
      <form action={formAction}>
        <input name="message" />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

The implementation principle of useOptimistic: while an action is in flight, it substitutes the "optimistic state" (the intermediate state applied via addOptimisticMessage) for the real state during rendering. When the action completes (successfully or with an error), React automatically switches back to the real state, discarding the optimistic state. This switch happens inside a transition, so there is no visual flash.

use(): Reading Resources During Render

use() is the most revolutionary Hook in React 19 — it breaks the previous convention that "Hooks can only be called at the top level of a React component function," allowing calls inside conditionals, loops, and even ordinary functions.

Reading a Promise

import { use, Suspense } from 'react';

// Create the Promise outside the component (not inside, to avoid recreating it on every render)
const userPromise = fetchUser(userId);

function UserProfile() {
  // use() reads the Promise — if it hasn't resolved yet, the component suspends
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
}

use(promise) works similarly to throw promise (the traditional Suspense trigger), but is more ergonomic: when the Promise has not yet resolved, use() throws the Promise, triggering the nearest Suspense boundary to display its fallback. When the Promise resolves, React re-renders the component and use() returns the resolved value directly.

// use() can be called inside conditionals
function DataDisplay({ condition, promise1, promise2 }) {
  const data = use(condition ? promise1 : promise2);
  return <div>{data}</div>;
}

Reading Context

use() can also read Context, equivalent to useContext, but it may be called inside conditionals:

function Button({ show }) {
  if (!show) return null; // use() can be called after this conditional return
  
  const theme = use(ThemeContext); // Equivalent to useContext(ThemeContext)
  return <button style={{ color: theme.color }}>Click</button>;
}

This feature simplifies many scenarios that previously required component restructuring — you no longer need to split a component in two just to "conditionally use a Context."

Internal Implementation of use()

// packages/react/src/ReactHooks.js (simplified)
export function use(usable) {
  return ReactCurrentDispatcher.current.use(usable);
}

// packages/react-reconciler/src/ReactFiberHooks.js
function useThenable(thenable) {
  // Track the current thenable's status
  const index = thenableIndexCounter++;
  const thenableState = getThenableStateAfterSuspending();
  
  // Check whether this Promise has already resolved
  switch (thenable.status) {
    case 'fulfilled': {
      const fulfilledValue = thenable.value;
      return fulfilledValue;
    }
    case 'rejected': {
      const rejectedReason = thenable.reason;
      throw rejectedReason;
    }
    default: {
      const prevThenableAtIndex = thenableState !== null
        ? thenableState.get(index)
        : null;
      
      if (prevThenableAtIndex === thenable) {
        // Same Promise — check its status
        switch (thenable.status) {
          case 'fulfilled': return thenable.value;
          case 'rejected': throw thenable.reason;
        }
      }
      
      // Subscribe to Promise status changes
      switch (thenable.status) {
        case 'pending':
          thenable.then(
            (fulfilledValue) => {
              if (thenable.status === 'pending') {
                thenable.status = 'fulfilled';
                thenable.value = fulfilledValue;
              }
            },
            (error) => {
              if (thenable.status === 'pending') {
                thenable.status = 'rejected';
                thenable.reason = error;
              }
            }
          );
          break;
      }
      
      // Throw the Promise to trigger Suspense
      throw thenable;
    }
  }
}

React Compiler: Automatic Memoization

React Compiler (formerly the React Forget project) is the most ambitious engineering effort in the React 19 ecosystem: a compiler that automates the manual useMemo/useCallback optimizations React developers have had to write by hand.

How It Works

React Compiler analyzes component code at build time (not runtime), determines which values and functions remain stable under which conditions, and automatically inserts memoization logic.

// The code you write
function ProductList({ category, onSelect }) {
  const filteredProducts = products.filter(p => p.category === category);
  
  return filteredProducts.map(product => (
    <ProductCard
      key={product.id}
      product={product}
      onSelect={onSelect}
    />
  ));
}

// What React Compiler produces (conceptual illustration)
function ProductList({ category, onSelect }) {
  const $ = useMemoCache(5);
  
  let filteredProducts;
  if ($[0] !== category) {
    filteredProducts = products.filter(p => p.category === category);
    $[0] = category;
    $[1] = filteredProducts;
  } else {
    filteredProducts = $[1];
  }
  
  let result;
  if ($[2] !== filteredProducts || $[3] !== onSelect) {
    result = filteredProducts.map(product => (
      <ProductCard
        key={product.id}
        product={product}
        onSelect={onSelect}
      />
    ));
    $[2] = filteredProducts;
    $[3] = onSelect;
    $[4] = result;
  } else {
    result = $[4];
  }
  
  return result;
}

The compiler uses an internal Hook called useMemoCache (not publicly exposed) to store cached values. It analyzes the component's data flow graph, identifies which computations depend on which props/state, and only recomputes when those dependencies change.

The Component Purity Assumption

React Compiler's operation assumes that components and Hooks must be pure (same inputs, same outputs, no side effects). The compiler analyzes code and detects potentially impure operations. If impurity is detected, the compiler skips optimizing that component rather than generating incorrect code.

// The compiler will skip optimization: directly mutating props or external variables
function BadComponent({ items }) {
  items.push('new item'); // Violates purity — compiler will abandon optimization
  return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
}

ref as Prop: Goodbye to forwardRef

In React 19, function components can receive ref directly as a prop, eliminating the need for forwardRef wrapping:

// React 18: requires forwardRef
const MyInput = forwardRef(function MyInput({ placeholder }, ref) {
  return <input ref={ref} placeholder={placeholder} />;
});

// React 19: receive ref directly as a prop
function MyInput({ placeholder, ref }) {
  return <input ref={ref} placeholder={placeholder} />;
}

// Usage is identical
function Parent() {
  const ref = useRef(null);
  return <MyInput ref={ref} placeholder="Enter text" />;
}

Internally in React 19, when rendering a function component, if the component's props contain a ref field, React passes it through directly without the intermediate wrapping layer. forwardRef remains valid in React 19 (for backward compatibility), but the official recommendation is to migrate progressively to the direct prop pattern.

Document Metadata: Declaring title and meta in Components

React 19 natively supports declaring document metadata inside components, without any third-party library:

function BlogPost({ post }) {
  return (
    <article>
      {/* These tags are automatically hoisted to <head> */}
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <link rel="canonical" href={post.url} />
      
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

React 19's renderer recognizes special tags like <title>, <meta>, and <link>, and "hoists" them to the document's <head>. In SSR scenarios, these tags are correctly placed inside <head> in the HTML output. In client-side rendering, React inserts them into <head> using DOM APIs.

When the same metadata tag appears in multiple components, React deduplicates — for example, if multiple components declare <title>, only the last one (or the highest-priority one) appears on the page.

Asset Loading APIs

React 19 adds a set of resource loading APIs for declaratively preloading critical assets inside components:

import { preload, preinit, prefetchDNS, preconnect } from 'react-dom';

function VideoPlayer({ src }) {
  // Preload the video resource (fetch with higher priority, but do not execute immediately)
  preload(src, { as: 'video' });
  
  // preinit: preload and execute immediately (for critical scripts and styles)
  preinit('https://cdn.example.com/player.js', { as: 'script' });
  
  return <video src={src} controls />;
}

function App() {
  // DNS prefetch and preconnect
  prefetchDNS('https://api.example.com');
  preconnect('https://api.example.com', { crossOrigin: 'anonymous' });
  
  return <VideoPlayer src="/video.mp4" />;
}

These APIs ultimately generate <link rel="preload">, <link rel="prefetch">, and similar HTML tags, helping the browser fetch critical resources earlier and improving page load performance. In SSR scenarios, these hints are written into the initial HTML so the browser begins loading resources in parallel as it parses the HTML.

Improved Error Reporting and Hydration Error Diffs

Better Error Grouping

React 19 improved the error capture mechanism. In earlier versions, a single error could appear in the console multiple times (once from React's internal handling, once captured by window.onerror). React 19 unifies the error reporting path:

// React 19 adds two new root options
const root = createRoot(container, {
  // Recoverable errors (caught by an ErrorBoundary)
  onRecoverableError: (error, errorInfo) => {
    console.error('Caught by ErrorBoundary:', error, errorInfo.componentStack);
  },
  // Uncaught errors (no ErrorBoundary caught this)
  onUncaughtError: (error, errorInfo) => {
    console.error('Uncaught error:', error, errorInfo.componentStack);
  },
});

Hydration Error Diff Display

React 19 shows the specific differences between server-rendered and client-rendered output when hydration fails, rather than the vague "hydration failed" message:

Hydration failed because the server rendered HTML didn't match the client.
  Server: <div class="container">Hello</div>
  Client: <div class="container">World</div>

This makes tracking down and fixing SSR inconsistencies far more direct.

React 18 → 19 Migration Guide

Breaking Changes

1. Removed legacy root APIs

// Removed (deprecated in React 18)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, container);        // ❌
ReactDOM.hydrate(<App />, container);       // ❌
ReactDOM.unmountComponentAtNode(container); // ❌

// Use the new APIs
import { createRoot, hydrateRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<App />);                       // ✅

2. Removed runtime propTypes validation

// React 19 completely removes runtime propTypes validation
MyComponent.propTypes = { ... }; // Has no effect — migrate to TypeScript

3. Removed defaultProps (for function components)

// In React 19, defaultProps for function components is removed
// Use ES default parameters instead
function Button({ color = 'blue', size = 'medium' }) {
  return <button className={`btn-${color} btn-${size}`}>Click</button>;
}
// Note: defaultProps for class components is still supported

4. ref callback cleanup functions

// In React 19, ref callbacks can return a cleanup function
function Component() {
  return (
    <div
      ref={(node) => {
        // setup
        if (node) {
          node.addEventListener('click', handler);
        }
        // cleanup (new in React 19)
        return () => {
          node.removeEventListener('click', handler);
        };
      }}
    />
  );
}

5. Simplified Context provider syntax

// React 18
<ThemeContext.Provider value={theme}>
  {children}
</ThemeContext.Provider>

// React 19: use the Context itself as a provider
<ThemeContext value={theme}>
  {children}
</ThemeContext>

Migration Priority

Priority Change Effort
Required Replace ReactDOM.render with createRoot Low
Required Remove propTypes Medium (recommend migrating to TypeScript)
Recommended Replace forwardRef with direct prop Medium
Recommended Replace Context.Provider with Context Low
Optional Replace useContext with use() Low
Optional Adopt Actions pattern to replace manual form state management High (high payoff)

The improvements in React 19 are not isolated feature additions — they all unfold around a central theme: making React code more about describing intent rather than describing implementation. Actions let you describe "submit this form" rather than managing pending/error state manually. The Compiler lets you describe "this is the component's render logic" rather than manually deciding which values to memoize. Document metadata lets you declare "what the title of this page is" inside a component rather than calling an external library's imperative API. This is the continuous evolution of React's design philosophy.

Rate this chapter
4.5  / 5  (3 ratings)

💬 Comments