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.