The React 18 migration guide for high-stakes production environments
Setting the stage: high stakes and high scale
At Sardine, our dashboard is the primary interface for our fraud detection and compliance platform. Compliance analysts, risk operators, and engineering teams use it every day to investigate transactions, manage rules, and monitor alerts. It is a large React and TypeScript monorepo with hundreds of components, dozens of internal packages, and deep integrations with backend services.
React 18 brought meaningful improvements, such as automatic batching, concurrent features, and a modernized rendering API. However, for a production application at our scale, the upgrade required more than a simple version bump.
The behavioral changes between React 17 and 18 are subtle enough to pass automated tests and manual QA. They often surface only under specific data conditions in production; for example, when a customer reports a frozen screen caused by an infinite re-render loop. These issues are difficult to find and even harder to fix after the fact. This reality shaped our strategy: we needed an approach that allowed us to validate changes incrementally with real traffic while maintaining the ability to revert instantly. A single large PR with a dedicated bug bash was considered, but a monolithic upgrade is hard to revert, hard to bisect when issues surface, and hard to fix under time pressure.*
This post covers the architecture we built to solve these challenges: a feature-flagged, per-user React version switch powered by import maps. We also share the codemods and custom ESLint rules we wrote to resolve the behavioral issues uncovered during the rollout.
Note: Code snippets throughout this post are simplified for clarity and do not represent the exact production implementation.
The technical challenge: subtle changes and silent failures
React 18 introduces changes that look simple on paper but behave unpredictably in large codebases. Two primary issues stood out during our migration:
Automatic batching changes when state updates flush
In React 17, updates inside promises or timeouts triggered a re-render for every setState. React 18 batches these into a single pass. While usually a performance win, code that reads the DOM or a ref immediately after a setState call, expecting an intermediate render, will now see "stale" data because the render hasn't flushed yet. This fails silently; the UI looks correct eventually, but the logic in between is broken.
Stricter referential equality exposes unstable references
React 18's reconciliation is more aggressive about bailing out of re-renders when props haven't changed. This is correct behavior, but it surfaces a class of bugs that React 17 quietly tolerated: inline [], {}, and () => {} used as default values create new references on every render. This triggers infinite re-render loops when consumed by useEffect, useMemo, or libraries like @tanstack/react-table that rely on referential equality.
We knew these issues would be nearly impossible to catch comprehensively before shipping. That's why we didn't treat this as a version bump; we treated it as a controlled rollout, with infrastructure designed to find and fix problems incrementally.
Note: Since our dashboard is a pure SPA with no server-side rendering, we avoided React 18's stricter hydration mismatch enforcement entirely (if your application uses SSR, expect a wave of hydration errors during migration).
Choosing the right path: from codemods to infrastructure-level switching
Before building a custom solution, we evaluated existing tools and architectures to find the safest possible rollout path.
Official migration tooling
The React team provides react-codemod, a set of automated migration scripts. We ran the relevant transforms: updating deprecated lifecycle methods, migrating type definitions (removing React.VFC in favor of React.FC where it removed implicit children too, updating render return types), and applying the update-react-imports codemod for the new JSX transform. These handled the mechanical, API-level changes and were a necessary first step.
But codemods can only fix what's syntactically identifiable. The behavioral issues (unstable references, batching changes) aren't syntax problems. They're runtime problems that depend on how components interact with each other and with data. No codemod can detect that your useEffect will loop infinitely under a specific combination of props. For that, we needed a way to run the new version against real traffic.
Version switching strategies
We considered three approaches and we have listed them in the table below:
Import maps gave us the most flexibility with the least infrastructure overhead. Switching React versions is a server-side string replacement. No redeployment, no separate build pipeline, no additional containers. The one caveat is browser support, since import maps are a relatively recent web standard. Before committing to this approach, we cross-referenced our analytics data with Can I use and confirmed that 100% of our active user base runs browsers with full support. As a B2B product used by compliance and risk teams, our browser landscape is predictable and current, which made this a safe bet.

The architecture: remote library asset version switching
Instead of bundling React into our application, we externalize it in production builds. The server checks a feature flag and injects the appropriate import map into index.html before serving it.
Since the import map is generated on the server, we can control the asset source per environment without any friction. If we needed to, we could serve React from our own S3 bucket with no code changes.

First, we externalized React, so that we don't have it included in the final bundle:
// vite.config.ts
export default defineConfig({
// ...
// Production: React externalized, loaded via import map
external: isProd
? ["react", "react-dom", "react-dom/client", "react/jsx-runtime"]
: [];
// ...
})In development, Vite bundles React 18 directly, so every engineer works against the target version during their normal workflow.
The server-side logic:
const isReact18 = req.featureFlags?.isEnabled("react-upgrade", {
userId: req.currentUser?.id,
... // a few more attributes
}) ?? false;
const version = isReact18 ? "18.3.1" : "17.0.2";
const esmUrl = (pkg) =>
`https://cdn.jsdelivr.net/npm/${pkg}@${version}`;
const imports = {
"react": esmUrl("react"),
"react-dom": esmUrl("react-dom"),
"react/jsx-runtime": esmUrl("react"),
};
The HTML template contains a placeholder that gets replaced with the import map.
From:
<script type="importmap">{{ REACT_IMPORT_MAP }}</script>To:
<script type="importmap">
{
"imports": {
"react":"[cdn]/react@[version]",
"react-dom":"[cdn]/react-dom@[version]",
"react/jsx-runtime":"[cdn]/react@[version]"
}
}
</script>
By doing that, the browser engine will resolve natively all import React from 'react', calling the CDN.
Dual rendering entry point
The application entry point detects which React API is available at runtime:
// Example code
import("react-dom")
.then(({ createRoot, render }) => {
if (createRoot) {
createRoot(container).render(app);
} else {
render(app, container);
}
})
A phased rollout strategy

The rollout followed five phases, each gated by the feature flag:
Phase 0: Pre-merge
All migration code merged to main with the flag defaulting to off. Production continued running React 17 with zero changes.
Phase 1: Development
Engineers ran their daily work against React 18 locally (Vite always bundles React 18 in dev). This caught issues early in the normal development flow.
Phase 2: Sandbox
Our Sandbox environment serves two purposes: customers use it to evaluate our platform, and every release deploys there before production. We enabled the flag here first. This gave us weeks of real usage patterns; customers running their own evaluation workflows, without any risk to production.
Phase 3: Internal users
Flag enabled for Sardine employees in production. Because the feature flag supports per-user and per-organization targeting, we could scope the rollout precisely: first our engineering team, then the broader company. This let us exercise critical workflows on live data before any customer saw the change.
Phase 4: Gradual rollout
Percentage-based rollout to external users, ramping from 5% to 100%. Because the React version is determined server-side on each page load, there's no cached state to invalidate. Toggle the flag, and the next request gets the previous version.
What we found: unstable references at scale
Once the rollout reached internal users on Local Environment and Sandbox, we started hitting infinite re-render loops. Components that worked perfectly under React 17 would freeze the browser under React 18. The root cause was always the same class of issue: unstable references.
Here's a simplified example of the pattern:
// A hook returning fallback defaults
function useFilters() {
const { data } = useQuery(...);
return {
filters: data?.filters ?? [], // new [] on every call
onChange: () => {}, // new function on every call
};
}
Every time this hook runs, the fallback [] and () => {} create new object references. Any downstream useEffect or useMemo depending on these values re-executes, which can trigger another render, which creates new references again, entering an infinite loop.
React 17 tolerated this because its reconciliation was less aggressive about reference checks. React 18 surfaces these bugs more aggressively due to Strict Mode double-invoking effects in development and Concurrent Rendering scheduling updates differently. React 18 does the right thing, but doing the right thing exposed years of accumulated shortcuts.
Stable constants
The fix is a set of frozen singleton references:
// react-migration-support.ts
export const STABLE_EMPTY_ARRAY = Object.freeze([]);
export const STABLE_EMPTY_OBJECT = Object.freeze({});
export const STABLE_NOOP_FUNC = Object.freeze(() => {});
When applied, they look like:
import { STABLE_EMPTY_ARRAY, STABLE_NOOP_FUNC } from "@/react-migration-support";
function useFilters() {
const { data } = useQuery(...);
return {
filters: data?.filters ?? STABLE_EMPTY_ARRAY,
onChange: STABLE_NOOP_FUNC,
};
}
This was a simple concept. The challenge was applying it consistently across hundreds of files. And, we needed to move fast, because these issues were blocking the final rollout.
Codemods for automated transformation
We built two jscodeshift codemods to handle the repetitive work:
fix-unstable-hook-returns scans every function matching use[A-Z]* and replaces return values containing [], {}, or () => {} with the corresponding stable constant.
fix-unstable-destructuring-defaults catches the destructuring variant:
// Before
const { data = [] } = useQuery(...);
// After
const { data = STABLE_EMPTY_ARRAY } = useQuery(...);
Both codemods manage imports automatically, adding or extending the @/react-migration-support import as needed.
In the end, after all the codemods had run, we had over 1k files changed.
Custom ESLint rules to prevent regression
While remediating the existing codebase was necessary, preventing regressions during the multi-phase rollout was equally critical. To enforce these patterns automatically and ensure the migration's long-term stability, we developed five custom ESLint rules:
The no-unstable-react-table-props rule is worth highlighting. @tanstack/react-table uses referential equality internally. If data or columns aren't memoized, the table re-renders on every parent update. This rule tracks useMemo and useState declarations and flags any unmemoized value passed to useReactTable:
// Flagged — rows is a local variable, not memoized
useReactTable({ data: rows, columns: cols });
// Passes — both are wrapped in useMemo
const data = useMemo(() => rows, [rows]);
const columns = useMemo(() => cols, [cols]);
useReactTable({ data, columns });
These rules remain useful well beyond the migration. The patterns they enforce are good practice regardless of React version, and they'll catch the same class of issues when we eventually move to React 19.
Final thoughts: a blueprint for high-scale migrations
- Import maps are underrated for version migration. They allowed us to implement per-user version switching without requiring additional build infrastructure. While there is an external CDN dependency, it is manageable through reliable providers like jsDelivr or by adding a caching proxy.
- Invest in prevention, not just remediation. While codemods were essential for fixing existing code in a single pass, our custom ESLint rules ensure those same patterns never return. These rules took effort to write, but they pay for themselves every time they catch an error before a commit rather than as a production bug report.
- Feature flags transform high-stakes upgrades into routine deployments. The ability to target specific users, organizations, or percentages of traffic gave us total control over the rollout. This granularity, combined with the ability to perform an instant rollback, removed the traditional time pressure associated with major version bumps.
- Address problems in production, safely and without time pressure. The unstable reference issues we discovered would have been nearly impossible to catch through isolated testing alone. Our import map architecture created a safe environment to find these edge cases using real traffic and data, backed by the safety net of an instant revert.
Looking ahead: future-proofing for React 19 and beyond
The beauty of the import map architecture is that it is entirely version-agnostic. When React 19 arrives, this same infrastructure will handle the rollout with the same level of precision and safety.
The pattern we built—externalizing a core dependency, version-switching via import maps, and gating the transition with feature flags—is not React-specific. It provides a repeatable, reversible, and gradual rollout path for any major library upgrade in a mission-critical environment.



.png)

