Engineering
Engineering

The React 18 migration guide for high-stakes production environments

Brendon Matos
Brendon Matos
8 min read
bg-image
bg-image
The React 18 migration guide for high-stakes production environments
SUBSCRIBE
Share

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:

Approach

Pros

Cons

Import maps + CDN

No rebuilds required; enables instant rollbacks and granular per-user targeting.

Introduces CDN/Static ESM dependencies and a slight latency bump when loading React.

Dual npm aliases

Operates entirely without a CDN dependency.

Requires dual build artifacts, resulting in larger bundles and significant infrastructure overhead.

Kubernetes-based (Airbnb's approach)

Provides complete process isolation.

Heavy infrastructure requirements, overkill for our setup.

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.

__wf_reserved_inherit

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.

__wf_reserved_inherit

First, we externalized React, so that we don't have it included in the final bundle:

In development, Vite bundles React 18 directly, so every engineer works against the target version during their normal workflow.

The server-side logic:

The HTML template contains a placeholder that gets replaced with the import map.

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:

A phased rollout strategy

__wf_reserved_inherit

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:

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:

When applied, they look like:

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:

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:

Rule

What It Catches

no-unstable-default-props

Inline literals as component default props.

no-unstable-hook-return

Unstable return values from custom hooks.

no-unstable-destructuring-defaults

const { data = [] } = useHook() patterns.

no-unstable-react-table-props

Unmemoized data/columns in useReactTable

no-unmemoized-array-transform

.map() / .filter() results used directly in render.

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:

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.