React 18 shipped on the twenty-ninth of March. The upgrade is a package bump and one changed line, and the announcement describes concurrent rendering as opt-in — which is true and understates what happens, because opting in changes when every state update is applied and that is not a local change.
The symptom
$ npm i react@18 react-dom@18
$ npm test
Tests: 388 passed, 24 failed
# ✗ shows a spinner while saving
# ✗ measures the list height after loading
# ✗ calls the analytics endpoint once
# ✗ closes the modal after the request
# ... 20 more
# and in the browser console, before any of that:
# Warning: ReactDOM.render is no longer supported in
# React 18. Use createRoot instead.Twenty-four failures from a version bump that had not yet been opted into, because StrictMode in development now double-invokes effects and the legacy render call warns. Both are deliberate and both are telling you about defects that already existed.
Why it happens
React had always applied updates synchronously and in the order they were requested, and a great deal of code came to depend on that — usually without anybody deciding to. Concurrent rendering makes rendering interruptible, which means an update can be started, abandoned and restarted, and the assumption that a render happens once per update stops holding.
The fix
The one-line opt-in, and what it enables
// 17, and still supported in 18 with a warning
import ReactDOM from 'react-dom'
ReactDOM.render(<App />, document.getElementById('root'))
// 18
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')).render(<App />)
// note the import path: react-dom/client.
// keeping the old call opts you out of automatic batching,
// useTransition and useDeferredValue — silently.
Leaving the old call in place is a supported first step and means the concurrent features do nothing, which is confusing when somebody adds useTransition and measures no difference. The console warning is the only signal and is one line among whatever else is there.
Automatic batching, and the code that depended on its absence
async function save() {
setSaving(true)
await api.save(draft)
setSaving(false)
setSaved(true)
// 17: two renders — one after each setState
// 18: one render, after both
}
// what breaks: code reading the DOM between two updates
setHeight(0)
const measured = ref.current.offsetHeight // 17: 0. 18: the old value.
setHeight(measured)
Fewer renders is the intended benefit and the breakage is entirely in code that had come to rely on the inconsistency — a measurement taken between two updates, or a third-party widget synchronised by hand. Nine of the twenty-four test failures were this shape.
import { flushSync } from 'react-dom'
flushSync(() => setHeight(0))
const measured = ref.current.offsetHeight
setHeight(measured)
// flushSync forces a synchronous render and is a
// performance escape hatch. more than two or three uses
// in an application means something else is wrong.
StrictMode, and the effects that were always broken
// 18 in development mounts, unmounts and remounts every
// component once. an effect without cleanup runs twice.
useEffect(() => {
analytics.track('page_viewed', { path })
}, [path])
// → two events per view, in development only
useEffect(() => {
const socket = connect(roomId)
return () => socket.close() // ← was missing
}, [roomId])
Every failure it produces is a real defect: a subscription with no cleanup, a fetch that races itself, an analytics event fired twice on a genuine remount. The temptation to remove StrictMode in the first week is strong and it hides bugs that appear in production as a slow leak, which is much harder to attribute.
The analytics case is the one with no clean answer, because the event genuinely should fire once per view and there is no cleanup that undoes a sent event. A ref guarding the first invocation works and is the pattern the documentation reluctantly describes; moving the call out of an effect entirely is better and is a larger change.
Transitions, and the difference between urgent and not
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function onChange(e) {
setQuery(e.target.value) // urgent: the input
startTransition(() => {
setResults(searchLocally(e.target.value)) // interruptible
})
}
// the input stays responsive because React can abandon
// the results render when another keystroke arrives.
This only helps when the slow part is rendering rather than fetching — a transition wrapping an async call does nothing useful, which is the most common misuse and produces a confident refactor with no measurable effect. The filtered list rendering four thousand rows is the case it is for.
// the same mechanism for a value received as a prop
function Results({ query }) {
const deferred = useDeferredValue(query)
const stale = query !== deferred
return (
<div style={{ opacity: stale ? 0.6 : 1 }}>
<ExpensiveList query={deferred} />
</div>
)
}
const ExpensiveList = memo(function ExpensiveList({ query }) { /* ... */ })
The memo is not optional: deferring a value only helps if the expensive child skips rendering when the value has not changed. Comparing the current and deferred values gives a stale indicator for free, which is a better loading state than a spinner because the previous content stays readable.
useSyncExternalStore, and why every store library shipped
const width = useSyncExternalStore(
(cb) => {
window.addEventListener('resize', cb)
return () => window.removeEventListener('resize', cb)
},
() => window.innerWidth, // client snapshot
() => 1024, // server snapshot
)
// getSnapshot must return a CACHED value. returning a new
// object each call is an infinite render loop, and the
// error message says the snapshot changed rather than why.
Concurrent rendering can render the same component twice with different store values, which produces a torn interface — half the screen showing the old state. The hook exists so that a store can tell React how to subscribe safely, which is why every state library released a version within weeks and why application code rarely calls it directly.
What is not in 18
in 18, and stable:
createRoot, automatic batching, useTransition,
useDeferredValue, useId, useSyncExternalStore,
Suspense consistency between server and client
not in 18, whatever the conference talks suggested:
server components — experimental, framework-only
suspense for data fetching in application code —
it works, and the documentation says explicitly
that it is not a public API. use a library.
the compiler. that is years away.Being clear about this internally mattered, because the discussion around the release blurred what shipped with what was demonstrated. Throwing a promise to suspend on data works and has genuinely difficult cache semantics, which is why the recommendation is to use a library rather than to write one.
The migration, in the order that worked
1 bump the packages, keep ReactDOM.render
→ 24 test failures, all StrictMode. fix them.
→ every one was a missing cleanup or a double-fire.
2 update the state libraries
→ they need useSyncExternalStore. do this before
createRoot or the tearing is real.
3 switch to createRoot
→ 9 more failures, all automatic batching.
4 add transitions, deliberately, where measured
→ two places. not eleven.
steps 1-3 took a fortnight. step 4 took an afternoon.Doing the library updates before the root swap is the ordering that matters, because a store not using the new hook under concurrent rendering produces tearing that is intermittent and extremely hard to attribute. Every other order works and produces a worse fortnight.
Verifying it worked
$ npm test
Tests: 412 passed
$ npx playwright test
41 passed
# and the profiler, on the search page that motivated it
# keystroke → input updates: 17: 180ms 18: 12ms
# keystroke → list updates: 17: 180ms 18: 190ms
#
# the list is no faster. the input stopped waiting for it,
# which is the entire point and is not a throughput win.Reporting that the list did not get faster is the honest framing, and it is the one that gets misrepresented — concurrent rendering does not make rendering faster, it makes the browser stay responsive while it happens. A summary claiming a fifteenfold improvement would have been quoted back later.
What this costs
A mental model that is now about priority rather than order, which is a genuine increase in the difficulty of reasoning about a component. “This state update happens after that one” was true and is now approximately true, and the cases where it is not are the cases that produce bugs.
The double-invoked effects are also a permanent development-time cost. Every new effect has to be written to tolerate running twice, which is correct discipline and is friction on every component — and the pressure to disable StrictMode returns every time somebody hits it with a deadline.
The honest summary of the fortnight is that the upgrade fixed twenty-four latent defects, enabled two measurable interaction improvements, and made the framework harder to reason about. All three are true and the first is worth more than the second — which is not how any of the release material described it.