Migrating to React 18: A Field Diary

By Odilon10 min read

Migrating to React 18: A Field Diary#

TL;DR

  • Switch to createRoot first — 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)#

bash
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:

tsx
// 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")
);
tsx
// 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:

tsx
// 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.

tsx
// 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:

tsx
// 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:

tsx
// 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:

tsx
// 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:

tsx
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.

tsx
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:

tsx
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:

tsx
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#

ChangeWhat You'll SeeFix
ReactDOM.render deprecatedConsole warningSwitch to createRoot
ReactDOM.hydrate deprecatedConsole warningSwitch to hydrateRoot
StrictMode double-effectsEffects fire twice in devAdd missing cleanup functions
Automatic batchingStale DOM reads between state updatesUse flushSync where needed
renderToString + SuspenseRenders fallback instead of throwingCheck SSR output
unstable_ Concurrent APIs removedBuild errorsSearch 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.render with createRoot in every entry point
  • Replace ReactDOM.hydrate with hydrateRoot if doing SSR
  • Search for unstable_ConcurrentMode and unstable_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
bash
# don't forget this one, learned the hard way
npm install @testing-library/react@latest @testing-library/user-event@latest

After the upgrade:

  • Add useId wherever 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.

Migrating to React 18: A Field Diary | Blog