Trust is earned, not given

A different perspective

2022-04-12 · Projects

React.js, part 1: the React 18 concurrent root — createRoot, automatic batching, and what actually changed

Part 1from the React.js series · 20 parts in all

React 18 did not look like a big release from the outside — the component API barely moved — but under the surface it replaced the renderer's core with a concurrent one. Part 1 is the mental model: the new root, what concurrent rendering actually buys you, and the small behavioral change (automatic batching) that quietly fixed a whole class of bugs.

createRoot: the old entry point is gone

For a decade you mounted an app with ReactDOM.render. React 18 keeps that function around for compatibility but it opts the tree out of every new feature. The supported entry point creates an explicit root object instead:

import { createRoot } from 'react-dom/client';

// React 17 and earlier: ReactDOM.render(element, container)
// React 18: create a root once, then render into it.
const root = createRoot(document.getElementById('root'));
root.render(<App />);

// Re-rendering is still a root call, and teardown is explicit too.
root.render(<App version="2" />);
// root.unmount();   // replaces ReactDOM.unmountComponentAtNode

The root object is the boundary of everything concurrent: transitions, Suspense streaming, and the new hydration strategy all operate on the trees it owns. A legacy ReactDOM.render call next to a createRoot call is the classic "why isn't my transition working" bug — the two trees cannot cooperate.

Concurrent rendering, in one sentence

Concurrent means React can interrupt a render, keep the previous UI on screen, and resume later. You do not write a new kind of component; you opt specific updates into being interruptible with startTransition (part 3). The payoff is that a large, slow list no longer freezes the typing in the search box above it.

Automatic batching: the behavior change that fixed bugs

Before 18, React batched state updates only inside its own event handlers. An update in a setTimeout, a promise callback, or a native DOM listener each caused its own render. In 18, updates are batched everywhere:

function Cart() {
  const [items, setItems] = useState(0);
  const [total, setTotal] = useState(0);

  function add() {
    // React 18 batches these wherever they run - one re-render, not two.
    setTimeout(() => {
      setItems(n => n + 1);
      setTotal(t => t + 9.99);
    }, 0);
  }
  return <button onClick={add}>Add item</button>;
}

If you truly need the old un-batched behavior, flushSync is the deliberate opt-out — reach for it only when you are reading a DOM measurement immediately after an update. Next: StrictMode, the feature that makes your effects tell the truth.