When to Switch to createRoot: A Practical Guide to React Fiber and Concurrent Rendering
React Fiber enables interruptible rendering, but only if you opt in with createRoot. Learn how concurrent features like useTransition keep UIs responsive—and what they change about effect timing.
23 May 2026, 22:36 UTC

The Janky Filter Problem
You add a search filter to a list of thousands of items. The user types one character, and the input stutters. In Chrome DevTools, you see a long scripting block that locks the main thread. React is reconciling the entire tree in one go, and the browser cannot paint the next frame until it finishes.
The fix is not a faster machine. It is understanding that React Fiber can pause reconciliation between units of work—but only if you opt in with ReactDOM.createRoot and structure expensive updates correctly.
Fiber Replaces the Stack with a Linked List of Work
Before Fiber, React used a recursive stack reconciler. Once rendering began, it ran to completion. There was no way to interrupt it, so a heavy component tree blocked everything else, including user input and animations.
Fiber changes the internal representation. Each component becomes a node in a linked-list tree, and each node is a unit of work. React can pause, resume, or abandon work between nodes. When a frame budget of roughly 16 milliseconds is exceeded, the renderer yields to the browser. This is called time-slicing.
Fiber also assigns updates to priority lanes—bitmask values such as Sync, InputContinuous, Default, and Idle. A keystroke gets higher priority than a background data fetch. Because lanes are bitmasks, React can batch or split updates without extra allocations.
The Engineering Decision: createRoot vs. ReactDOM.render
Concurrent rendering is opt-in. Legacy ReactDOM.render creates a synchronous root: no interruptible rendering, no automatic batching, no transitions. ReactDOM.createRoot enables the full Fiber machinery for that tree.
If you are on React 18 or later and still calling ReactDOM.render, you are leaving Fiber's benefits unused. The first practical step is migrating your entry point:
// Before (legacy, synchronous)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// After (concurrent)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
Run this in your application's entry file (for example, src/index.js or src/main.jsx). No special permissions are required. Expected check: your existing app should behave identically, but concurrent features become available.
Worked Example: Keeping a Filter Responsive
Suppose filtering a large list is CPU-intensive. Without help, every keystroke triggers a synchronous reconciliation and the input feels frozen. You can split the update into an urgent part (the input value) and a non-urgent part (the filtered list) using useTransition.
In your component file, structure the handler like this:
import { useTransition, useState } from 'react';
function FilterableList({ items }) {
const [query, setQuery] = useState('');
const [list, setList] = useState(items);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const nextQuery = e.target.value;
setQuery(nextQuery); // urgent: update the input immediately
startTransition(() => {
setList(filterItems(items, nextQuery)); // non-urgent: heavy reconciliation
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <span>Updating…</span>}
<HeavyList items={list} />
</div>
);
}
Replace filterItems with your real filtering logic and HeavyList with your list component. Expected check: while the list reconciles, the input remains responsive and the "Updating…" indicator appears.
If your data layer integrates with Suspense—for example, React Query v5+, SWR, or a framework like Next.js—you can wrap the list in <Suspense fallback={<Spinner />}>. React will keep showing the current UI until the transition's higher-priority work is ready. A raw fetch inside useEffect does not integrate with Suspense on its own.
Trade-offs and Limitations
Interruptible rendering changes timing assumptions you may rely on today.
- Effect cleanup is not guaranteed to be immediate. A
useEffectcleanup runs before the next effect, but a higher-priority update can delay it. Do not assume a subscription or timer is torn down the instant a component unmounts. - Error boundaries have limits. They catch errors during the render and commit phases of their subtree. Errors thrown inside
useEffector event handlers are not caught; they propagate towindow.onerror. - flushSync defeats concurrency. Calling
flushSyncforces a synchronous commit. It is useful when integrating with non-React DOM libraries, but overusing it reintroduces the blocking behavior Fiber is designed to avoid. - Internal APIs are unstable. Scheduler functions such as
unstable_scheduleCallbackare not covered by semantic versioning. Avoid them in application code.
Actionable Next Steps
- Audit your root: replace
ReactDOM.renderwithReactDOM.createRootin your entry file. - Open Chrome DevTools, record a Performance profile while typing in an expensive input, and look for yielded frames between reconciliation work.
- Wrap heavy state updates in
startTransitionso urgent input stays on the fast path. - Review any
useEffectcleanups that manage external resources; add defensive guards rather than assuming synchronous teardown. - Verify the result: the input should remain responsive while the heavy list updates in the background.
Fiber is not magic. It is a scheduling contract. You get responsiveness when you declare which updates are urgent and which can wait—and when you remember that a paused render means side effects may not happen exactly when you expect.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.