Migrating to React 18: A Field Diary
Migrating to React 18: A Field Diary#
TL;DR
- Switch to
createRootfirst — that unlocks everything else- The StrictMode double-effect behavior will expose every missing cleanup you've been ignoring
- Automatic batching is free performance, but watch out for synchronous DOM reads between state updates
- The actual API change is two lines. The effect audit is where your weekend goes.
I've been running RC builds on side projects for several weeks and recently migrated an internal dashboard at work. This is a step-by-step account of what actually happened, not what the announcement blog post says should happen.
Day 1: The Install (Nothing Breaks, Which Is Suspicious)#
npm install react@rc react-dom@rc
# TypeScript people, don't skip this
npm install --save-dev @types/react@rc @types/react-dom@rc
After the install, I ran the app. Everything worked. Console printed one deprecation warning about ReactDOM.render running in legacy mode. That's it.
This felt too easy. The real work starts on day 2.
Day 2: The createRoot Switch#
This is the real migration. Find your entry point and replace ReactDOM.render:
// Before — the way we've been doing it since forever
import ReactDOM from "react-dom";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// After — welcome to concurrent mode (sort of)
import { createRoot } from "react-dom/client";
const container = document.getElementById("root");
if (!container) throw new Error("Root element not found");
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
If you're doing SSR with hydration:
// Before
import ReactDOM from "react-dom";
ReactDOM.hydrate(<App />, document.getElementById("root"));
// After
import { hydrateRoot } from "react-dom/client";
hydrateRoot(document.getElementById("root")!, <App />);
Two lines changed. Deployed to staging. And then StrictMode started screaming.
Day 3-5: The useEffect Cleanup Audit (aka The Real Work)#
This is where I lost three days. React 18's StrictMode now double-fires effects in dev: mount, cleanup, mount again. It's simulating what the future "offscreen" API will do. In production nothing changes — this is dev-only.
But here's the thing: it exposes every effect that's been silently leaking since you wrote it. We had a LOT of those.
// This was all over our codebase. Fires analytics TWICE in dev now.
useEffect(() => {
analytics.track("page_viewed", { page: location.pathname });
}, [location.pathname]);
// And this gem — opens TWO WebSocket connections
useEffect(() => {
const socket = new WebSocket(WS_URL);
socket.onmessage = handleMessage;
// cleanup? what cleanup?
}, []);
The fixes are straightforward once you know what to look for:
// WebSocket: just add the damn cleanup
useEffect(() => {
const socket = new WebSocket(WS_URL);
socket.onmessage = handleMessage;
return () => {
socket.close(); // how did we ship without this
};
}, []);
// Analytics: ref guard (ugly but works)
const didTrack = useRef(false);
useEffect(() => {
if (didTrack.current) return;
didTrack.current = true;
analytics.track("page_viewed", { page: location.pathname });
}, [location.pathname]);
The ref guard is gross. A better approach is moving one-time side effects out of React entirely — fire them from your router's route change callbacks or your data layer. But the ref guard ships today and you can refactor later.
Global event listeners were another recurring problem:
// Broken: adds listener twice, never removes it
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
// Fixed: 10 seconds of work, should've been there from day 1
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleKeyDown]);
Going through every useEffect in a typical codebase of this size — about 47 of them — roughly a quarter had no cleanup when they should have. The React lint rule catches missing deps but not missing cleanups — this is a manual job.
Day 6: Automatic Batching (The Free Win)#
React 18 batches ALL state updates now — including inside setTimeout, Promises, and native event handlers. In React 17, only React event handlers got batching.
For us this was a free performance boost. No code changes. But there's one edge case that bit us:
// React 17: two renders, DOM measurement between them is fine
setTimeout(() => {
setShowPanel(true); // render 1
const height = panel.current.offsetHeight; // DOM updated
setPanelHeight(height); // render 2
}, 100);
// React 18: one render — offsetHeight reads stale DOM!
Fix with flushSync:
import { flushSync } from "react-dom";
setTimeout(() => {
flushSync(() => {
setShowPanel(true);
});
// NOW the DOM is updated
const height = panel.current?.offsetHeight ?? 0;
setPanelHeight(height);
}, 100);
Expect one or two instances of this. A dropdown positioning calculation that starts rendering in the wrong spot is a typical symptom.
Day 7: New Things Worth Using Right Now#
useId#
This alone justifies the upgrade for me. Generating IDs for accessibility attributes (htmlFor/id, aria-describedby) has been a pain forever. Math.random() causes hydration mismatches. Counters break with SSR. useId just works.
function EmailField() {
const id = useId();
return (
<div>
<label htmlFor={id}>Email address</label>
<input
id={id}
type="email"
name="email"
autoComplete="email"
/>
</div>
);
}
Multiple related IDs:
function PasswordField() {
const baseId = useId();
return (
<div>
<label htmlFor={`${baseId}-input`}>Password</label>
<input
id={`${baseId}-input`}
type="password"
aria-describedby={`${baseId}-hint`}
/>
<p id={`${baseId}-hint`}>Must be at least 8 characters.</p>
</div>
);
}
startTransition#
I covered this briefly in a previous post but now it's stable. Quick example:
import { useTransition } from "react";
function FilterableList({ items }: { items: Item[] }) {
const [isPending, startTransition] = useTransition();
const [filter, setFilter] = useState("");
const [filtered, setFiltered] = useState(items);
function handleFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
setFilter(e.target.value);
startTransition(() => {
setFiltered(applyFilter(items, e.target.value));
});
}
return (
<>
<input value={filter} onChange={handleFilterChange} placeholder="Filter..." />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{filtered.map((item) => (
<ItemRow key={item.id} item={item} />
))}
</div>
</>
);
}
Don't go crazy adding startTransition everywhere. Find the 2-3 places in your app where typing in an input triggers an expensive list re-render, and start there.
Breaking Changes Cheat Sheet#
| Change | What You'll See | Fix |
|---|---|---|
ReactDOM.render deprecated | Console warning | Switch to createRoot |
ReactDOM.hydrate deprecated | Console warning | Switch to hydrateRoot |
| StrictMode double-effects | Effects fire twice in dev | Add missing cleanup functions |
| Automatic batching | Stale DOM reads between state updates | Use flushSync where needed |
renderToString + Suspense | Renders fallback instead of throwing | Check SSR output |
unstable_ Concurrent APIs removed | Build errors | Search codebase for unstable_ConcurrentMode and remove |
Migration Checklist#
Before you start:
- Turn on StrictMode if it's not on already
- Fix every effect that has no cleanup (do this in a separate PR)
- Upgrade RTL to v13+ alongside React (
act()warnings everywhere otherwise)
The actual upgrade:
-
npm install react@18 react-dom@18(and@types/if TypeScript) - Replace
ReactDOM.renderwithcreateRootin every entry point - Replace
ReactDOM.hydratewithhydrateRootif doing SSR - Search for
unstable_ConcurrentModeandunstable_Profiler— remove them - Run your app in dev, watch the console for double-effect issues
- Check any code that reads DOM measurements between state updates
# don't forget this one, learned the hard way
npm install @testing-library/react@latest @testing-library/user-event@latest
After the upgrade:
- Add
useIdwherever you're generating accessibility IDs with hacks - Find your 2-3 worst input-lag components and try
startTransition
The React team managed to ship a major version where the actual breaking surface is small. The effect cleanup audit is the hard part, and honestly, those bugs were already in your codebase — React 18 just made them visible. Better now than in production at 3am.
Next post: a deep dive into useTransition and useDeferredValue. React 18 stable should be out by then.