React 18 Alpha: I've Been Burned Before, But This Looks Different
React 18 Alpha: I've Been Burned Before, But This Looks Different#
I've been burned by "this will change everything" before. GraphQL was going to replace REST. CSS-in-JS was going to kill CSS files. Concurrent mode has been "coming soon" since like 2019.
After spending a week with the React 18 alpha: the concurrent features actually deliver. Not in a "rewrite your whole app" way — in a "swap two lines and your search input stops lagging" way.
TL;DR
- React 18 ships a new root API (
createRoot) that unlocks concurrent featuresstartTransitionlets you mark state updates as non-urgent, keeping UI responsive during expensive renders- Automatic batching extends React 17's batching to async handlers, setTimeout, Promises -- everywhere
- Concurrent rendering is opt-in. Your app won't break.
The Mental Model: Urgent vs. Not Urgent#
The core idea is simple enough. Not all state updates are equally important.
Typing in a search box? Urgent. The input should feel instant. Rendering the 500-item filtered list that updates as you type? Not urgent. It's OK if that lags by a frame or two.
Before React 18, React had no way to express this. Every setState was equal priority. If your filter function took 50ms, it blocked the input update. The user typed three characters but only saw one appear. Laggy. Frustrating.
Concurrent rendering lets React pause, interrupt, and resume renders. Urgent stuff (input, clicks) jumps ahead. The browser stays responsive.
startTransition#
This is the API I'm most excited about. It's deceptively simple:
import { startTransition, useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
function handleChange(e) {
// Urgent: update the input immediately
setQuery(e.target.value);
// Non-urgent: filter results can wait
startTransition(() => {
const filtered = expensiveFilter(allItems, e.target.value);
setResults(filtered);
});
}
return (
<>
<input value={query} onChange={handleChange} placeholder="Search..." />
<ResultsList results={results} />
</>
);
}
What happens: setQuery runs at normal priority -- React commits it immediately, the input feels instant. The setResults inside startTransition is scheduled at lower priority. If the user types again before React finishes computing results, React throws away the in-progress render and starts a new one with the latest query.
That's the interruption feature. React has literally never been able to do this before. The previous workarounds — debouncing state updates, Web Workers for expensive filters, virtualized lists — are all still valid. But startTransition handles the 80% case in three lines.
There's also useTransition which gives you a pending flag:
import { useTransition, useState } from "react";
function SearchPage() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
function handleChange(e) {
setQuery(e.target.value);
startTransition(() => {
setResults(expensiveFilter(allItems, e.target.value));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? (
<p className="status">Filtering...</p>
) : (
<ResultsList results={results} />
)}
</>
);
}
isPending is true while the transition is in progress. Use it for a subtle indicator -- a dimmed state, a small spinner. Not a blocking overlay. The whole point is that the UI stays interactive.
Automatic Batching#
This one is less exciting but arguably more impactful for existing codebases. In React 17, batching only worked inside React event handlers:
// React 17: batched -- one re-render
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// single render
}
// React 17: NOT batched -- two re-renders
setTimeout(() => {
setCount(c => c + 1); // renders
setFlag(f => !f); // renders AGAIN
}, 1000);
React 18 with createRoot batches everything. setTimeout, Promises, native event listeners, everything:
// React 18: batched everywhere
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// single render
}, 1000);
async function fetchAndUpdate() {
const data = await fetchData();
setData(data);
setLoading(false);
// single render -- finally!
}
Free performance improvement. The only gotcha is code that intentionally relied on React 17's non-batching in async contexts -- code that set state then read from the DOM synchronously between two updates. That pattern was always fragile. If your codebase has it, now is the time to fix it.
If you ever need to force a synchronous flush:
import { flushSync } from "react-dom";
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated here
The New Root API#
This is the entry point for everything above. Old ReactDOM.render still works in React 18 (legacy mode, deprecation warning), but concurrent features require createRoot:
// React 17 (still works, but legacy mode)
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));
// React 18: concurrent mode enabled
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
Two lines change. That's the migration, assuming no deprecated lifecycle methods or legacy patterns.
Suspense Gets Real#
Suspense has been around since React 16.6 for code-splitting. React 18 extends it in two ways.
First, Suspense boundaries now compose properly with concurrent rendering. When a component suspends, React shows a fallback for just that subtree while continuing to render everything else.
function Dashboard() {
return (
<div>
{/* Each panel has its own fallback -- they're independent */}
<Suspense fallback={<MetricsSkeleton />}>
<MetricsPanel />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
Second, server-side Suspense is becoming real. renderToPipeableStream supports streaming HTML with Suspense-aware hydration. The server sends each Suspense boundary as its data becomes available instead of waiting for the whole page. I'll write about this properly when it ships in stable.
StrictMode Is Going to Break Your Code#
Fair warning. React 18 adds a new StrictMode behavior in development: it intentionally double-invokes effects (mount, unmount, mount) to surface cleanup bugs.
This is going to break a LOT of code. I've already seen it in the alpha. Effects that fire side effects without cleanup run twice. Analytics calls fire twice. WebSocket connections open twice.
The fix is always the same: add a cleanup function. But some patterns (like one-time analytics on mount) need rethinking. I'll have more to say when the stable release lands and we see how the ecosystem handles it.
What's Not in React 18 (Yet)#
The alpha doesn't include:
- Server Components (separate RFC, separate timeline)
- The "offscreen" API (deferred mounting for virtual lists, prerendering)
- Full concurrent Suspense data fetching without a library
The concurrent features in the alpha are mostly about rendering performance and interruptibility. The data fetching story still relies on libraries like Relay or the patterns from Suspense experimental.
My Take#
"Concurrent mode" sounded scarier than it is. The actual migration for most apps: swap ReactDOM.render for createRoot, audit your useEffect cleanups, add startTransition where you have expensive derived-state renders. That's it.
My recommendation: install the alpha on a side project now. Surface the cleanup issues before the stable release hits. The sooner you find effects that don't clean up properly, the less painful the real migration will be.
Migrating a typical project to the alpha takes an evening. Expect two to three effects that need cleanup fixes and possibly one analytics call that needs a ref guard.
What I'm Reading#
- React 18 Working Group -- the discussions here are phenomenal. The React team explains their thinking in detail
- The Plan for React 18 -- official blog post laying out the roadmap
- Behavioral changes in React 18 -- Andrew Clark's writeup on StrictMode changes
- Dan Abramov's replies in the working group threads -- worth reading even if you don't care about React 18 specifically. The explanations of state machines are exceptional
Next up I'll cover the stable release migration checklist once it ships. For now, go play with the alpha. It's more stable than you'd expect from an alpha.