Upgrading a Legacy Monorepo to React 18: What Nobody Warns You About
React 18 shipped in March 2022. We merged our upgrade in April 2026. If you do that math, yes — we were four years behind. This is a post about what that upgrade actually looked like across a large, multi-generational codebase, and the specific issues that ate most of the time.
Spoiler: the "breaking changes" in the official React 18 migration guide are the easy part.
The Starting Point
Our frontend is not one app — it's four generations of React code living in the same monorepo, accumulated over years of product growth:
assets/js/— the oldest layer, Alt.js Flux architectureapp/— second generation, class components everywhereweb/— Chakra UI-based, mostly hooksfrontend/— newest, Elastic EUI, greenfield
The upgrade touched the first two generations: assets/js/ and app/. That's the part of the codebase with the heaviest accumulation of legacy patterns — class components, Immutable.js collections in JSX, and some HOC patterns that had never seen a TypeScript type checker. The newer layers (web/ and frontend/) were already React 18-compatible.
437 files changed, 23 commits, labelled size/xl by the PR bot. Here's what was actually inside all of that.
The Obvious Stuff (5% of the work)
The React team's migration guide covers these clearly, so I won't belabor them.
ReactDOM.render → createRoot
// Before
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.querySelector("#register"));
// After
import { createRoot } from "react-dom/client";
createRoot(document.querySelector("#register")).render(<App />);
We only had a handful of entry points doing this. Straightforward.
ReactDOM.findDOMNode → refs
findDOMNode was already deprecated in strict mode. We had two components still using it. The fix is mechanical: add a React.createRef<HTMLDivElement>() class field, attach it via ref={this.containerRef}, and read this.containerRef.current instead of calling findDOMNode(this).
React.ReactNodeArray → React.ReactNode[]
React 18 deprecated the ReactNodeArray type alias. A straightforward find-and-replace across ~14 files.
None of these are surprises. The React docs tell you about all of them. The rest of this post is about the things they don't tell you — or at least don't emphasize enough.
The Sneaky One: children Is No Longer Implicit
This was the single largest category of change in the PR. ~35+ components needed updates because of it.
In React 17, React.FC<Props> implicitly included children in the prop type. You could pass children to any function component without declaring it. React 18 removed that. If your component renders children, you now have to say so:
// React 17 — this worked fine
const Card: React.FC<{ title: string }> = ({ title, children }) => { ... };
// React 18 — TypeScript error: Property 'children' does not exist
const Card: React.FC<{ title: string }> = ({ title, children }) => { ... };
// Fix option 1: use PropsWithChildren
const Card: React.FC<React.PropsWithChildren<{ title: string }>> = ({ title, children }) => { ... };
// Fix option 2: declare it explicitly in the interface
interface CardProps {
title: string;
children?: React.ReactNode;
}
const Card: React.FC<CardProps> = ({ title, children }) => { ... };
For class components and context providers, the fix was adding children?: React.ReactNode directly to the constructor argument type or props interface.
This is correct behavior — implicit children was always a footgun. But in a large codebase, it means touching every component that wraps children without having declared it. There's no automated codemod that handles every pattern, so this required a manual pass through a lot of files.
The Immutable.js Problem
This one bit us in unexpected places.
We use Immutable.js in the older parts of the codebase. In React 17, rendering the result of .map() on an Immutable.js List directly as JSX children worked, somewhat accidentally — React would accept the Immutable iterable.
React 18 is stricter about what counts as a valid child array. Immutable collections no longer pass. The fix is calling .toArray() at the end of every .map() chain:
// Before — worked in React 17
{items.map((item) => <Item key={item.id} item={item} />)}
// After — required for React 18
{items.map((item) => <Item key={item.id} item={item} />).toArray()}
We had this pattern in 11 files, including some complex nested .map() chains in components like GroupedSelection and the Propertyware integration settings. The failures were TypeScript errors, not runtime crashes, which made them catchable — but only if you actually ran the type checker across the whole codebase before shipping.
HOC Type Constraints Got Stricter
Higher-order components that wrap a component and return a new one had a rough time. React 18's generic constraints on React.ComponentType<P> became more strict, and some HOC patterns that TypeScript 4.x had silently accepted now produced type errors.
The specific failure was with our ModelUpdate HOC. The return type of the HOC didn't satisfy the wrapper's inferred signature under the new type constraints. The fix was an explicit cast at the call site:
// Before
export default ModelUpdate(MyComponent);
// After
export default ModelUpdate(MyComponent) as React.ComponentType<any>;
Not beautiful. But it's a type-level workaround, not a runtime concern — and it's localized to the HOC boundary. The real fix is to update the HOC's own generic signature, which we're doing incrementally.
Enzyme Is Dead, Long Live Testing Library
enzyme-adapter-react-16 — note the name — never got a React 18 equivalent. The Enzyme project has been effectively abandoned. If you're still setting up Enzyme in your test suite, this upgrade is the forcing function to finish the migration to @testing-library/react.
We had already moved most tests to Testing Library, so this was one line to delete from app/tests/setup.js:
// Gone
const Enzyme = require("enzyme");
const EnzymeAdapter = require("enzyme-adapter-react-16");
Enzyme.configure({ adapter: new EnzymeAdapter() });
If you're starting the React 18 upgrade and you still have significant Enzyme test coverage, budget time for that migration separately. Don't try to do it at the same time.
Test Timing: React 18 Flushes Updates More Eagerly
React 18's concurrent renderer flushes state updates differently than React 17. In practice, some E2E tests that had been passing were silently racing against render timing that React 17 happened to mask.
After the upgrade, ~8 Playwright tests started flaking. The root cause was tests that asserted on UI state immediately after triggering an action, without waiting for React to flush. In React 17, the synchronous renderer would complete before the assertion. In React 18, it doesn't always.
The fix was adding explicit expect(element).to_be_visible() waits at the points where tests were asserting on state that depends on a render cycle completing. Nothing dramatic — but the flakes were confusing until we understood the cause.
The OpenAPI File: A Cautionary Tale
One file in the PR has 6,763 lines of churn: assets/js/common/openapi/api.ts.
This is an auto-generated OpenAPI client. We don't own it — it's generated from our API schema. What happened is that upgrading @types/react (which React 18 requires) changed how the code generator emits TypeScript, producing a near-complete regeneration of the file even though the underlying API schema hadn't changed.
The diff looks enormous and scary. Every CI check that does a line-count heuristic on the PR screamed. But it's effectively a no-op — the runtime behavior is identical, just regenerated under new type definitions.
If you have auto-generated files in your codebase that depend on React types, expect this. Don't let it alarm your reviewers. The solution is to regenerate the file as part of the upgrade and include a clear note that the churn is mechanical.
One More: React.SFC Was Actually Removed
React.SFC was a very old alias for React.FC (it stood for "Stateless Functional Component"). It was deprecated in React 16.9. In React 18, the type is gone entirely.
We had two files still using it. Straightforward find-and-replace to React.FC. I'm mostly mentioning this because if you have a large codebase, there might be more of these lurking than you expect — grep for React.SFC before you start.
What Actually Took the Most Time
Looking back at the 23 commits and 437 files:
- ~40% of the file count is the
childrenprop changes — tedious but mechanical once you know the pattern - ~20% is Prettier reformatting files that were touched for other reasons — noise in the diff
- ~15% is the OpenAPI regeneration — zero logic change, lots of lines
- ~15% is the Immutable.js
.toArray()additions, HOC casts, and TypeScript annotation fixes - ~10% is actual behavioral changes:
createRoot,findDOMNode, Enzyme removal, E2E timing
The behavioral changes — the things that could actually break your app — are a small fraction of the total diff. The bulk of the work is type system cleanup that React 18 forces you to confront.
If You're About to Do This
A few things that would have made our upgrade faster:
Run the TypeScript compiler first, not last. tsc --noEmit across the full codebase before touching anything else gives you the complete list of type errors. It's faster to fix from a list than to discover them one at a time as you touch files.
Grep for the patterns before you start. Search for React.SFC, ReactDOM.findDOMNode, ReactDOM.render, .map() on Immutable collections returned directly to JSX, and Enzyme. Know the scope before the first commit.
The children prop changes are a good candidate for a codemod. We did them manually because our codebase has too many prop interface patterns to write a single transform for. But if your codebase is more uniform, a jscodeshift transform would save a lot of time.
Keep the Enzyme migration separate if it's not already done. Doing a library upgrade and a test framework migration in the same PR makes it hard to bisect if something breaks.
Four years late, but the codebase is better for it. Concurrent mode features aren't something we're reaching for immediately — but at least now we can.
Comments
Post a Comment