modern-react-spa

Chapter 27

Running Different React Versions Together

Mixed React-version SPAs — why they exist, four isolation strategies, sharing state across React trees, and what breaks across major versions.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can articulate why mixed-React-version SPAs exist (it’s never “for fun”), pick the right isolation strategy from four real options, build a shared-state bridge between two React trees without leaking either runtime, and list the React features that silently break across version boundaries.

🧭 Prerequisites — Ch 26 (Module Federation), Ch 13 (global state).


🔹 27.1 Why this happens in real companies

Five real causes:

  • Acquisitions — Co. A is on React 17; Co. B is on React 19. Merge UI before merge migration.
  • Slow migrations — 800k-LOC admin can’t be flipped in a quarter.
  • Vendor widgets — a third-party scheduler ships its own React.
  • Org boundaries — payments team owns React 18.3; growth team is on 19.2; neither will rev unilaterally.
  • Risk isolation — pin the high-revenue surface to a known-stable React; iterate on the rest.

“Just upgrade” is glib advice for a 12-team SPA. The TCO of running mixed versions is often lower than the TCO of forcing a unified migration on a schedule the org can’t sustain.


🔹 27.2 Isolation strategies — four options

StrategyBoundary strengthDX costPerf hitUse when…
iframeProcess-levelHighHighTruly untrusted or legacy black-box code
Shadow DOMDOM-levelMediumLowStyle/scope isolation, same JS realm
Multiple rootsNone (same realm)LowLowVersions are close enough (18 + 19)
Module FederationModule-graphMediumLowTwo SPAs need to behave as one

iframe

The blunt instrument. Two pages, two windows, communication via postMessage.

Pros: total isolation; React versions can’t possibly conflict. Cons: scroll behavior, deep linking, auth-cookie sharing all become problems. Heavy.

Use only for truly untrusted code (third-party widgets you don’t control).

Shadow DOM

Mount a sub-React tree inside element.attachShadow({ mode: 'open' }). Styles don’t leak in or out. JS realm is shared.

const host = document.querySelector('#widget')!;
const shadow = host.attachShadow({ mode: 'open' });
const root = createRoot(shadow);
root.render(<WidgetApp />);

Pros: style isolation; easier than iframe. Cons: still one JS realm — React invariants apply (one React only, no second copy hidden inside).

Multiple roots

createRoot twice in the same page with two different React bundles. The trick: rename one of them at build time so the singleton check doesn’t kick in.

// build-time alias: 'react' → 'react-legacy' for one bundle
// then both bundles can coexist without React's internal singleton guard tripping

Pros: simplest. Cons: fragile across DevTools, portals, third-party libraries that resolve ‘react’ dynamically.

Module Federation

The Ch 26 architecture, configured with singleton: false for React. Each remote ships its own React; coexistence is intentional.

shared: {
  react: { singleton: false, strictVersion: true },
  // ...
}

Pros: clean per-remote ownership; well-trodden path. Cons: bundle size doubles for the React core; cross-tree concerns (context, portals, Suspense) break (see §27.4).

Use the project’s runtime plugin hooks (module-federation.io → “Runtime” → “Plugin System”) to gate which remote is allowed which React version.

🔹 27.3 Sharing state across React versions

Users don’t care which React rendered which widget. They care that the cart count is consistent.

Three patterns:

Event bus

A vanilla-JS pub/sub the host owns. Both Reacts subscribe.

// shared/events.ts (vanilla — no React)
type Listener<T> = (payload: T) => void;
const bus = new Map<string, Set<Listener<any>>>();

export const on  = <T>(event: string, fn: Listener<T>) => {
  if (!bus.has(event)) bus.set(event, new Set());
  bus.get(event)!.add(fn);
  return () => bus.get(event)!.delete(fn);
};
export const emit = <T>(event: string, payload: T) => {
  bus.get(event)?.forEach((fn) => fn(payload));
};

Each React tree subscribes via useEffect:

useEffect(() => on<Cart>('cart-updated', setCart), []);

Broadcast channel

BroadcastChannel for cross-tab + cross-iframe propagation. Same shape as the event bus, different transport.

External store with useSyncExternalStore

The cleanest — a Zustand store outside both Reacts. Each React reads via useSyncExternalStore (or Zustand’s wrapper).

// shared/cartStore.ts
import { create } from 'zustand';
export const useCart = create<Cart>(/* … */);

// Both React 17 and React 19 import this same store.
// Reads via Zustand's useStore hook work in any React version 16.8+.
┌─ Shell (vanilla) ───────────────────────────────────────┐
│   ┌── React 17 widget ──┐    ┌── React 19.2 page ────┐  │
│   │  🛒 Cart: 3 items   │    │  Checkout              │  │
│   │  (legacy admin)     │    │  Items: 3 · $191.16    │  │
│   └─────────────────────┘    └────────────────────────┘  │
│                  ▲                       ▲               │
│                  └──────── shared cartStore ─────────────┘
└─────────────────────────────────────────────────────────┘

The external-store pattern is the modern default. Event bus and broadcast channel for cases where you can’t use Zustand (legacy code, cross-realm boundaries like iframes).


🔹 27.4 What breaks across versions

A hard-won list:

  • ContextContext object from React-A is unreadable in React-B (different object identities; React-A’s context-provider machinery doesn’t connect to React-B’s consumer machinery).
  • Portals — fine within one React; portaling into another React’s tree confuses both.
  • Suspense — boundary in React-A cannot suspend a child rendered by React-B (different fiber trees).
  • Hydration — never SSR-hydrate across versions; render client-side only.
  • DevTools — works, but the tree view can be confusing (two roots appear).
  • Strict Mode double-invoke — surprises when only one tree is in strict mode.

For each, the rule: don’t cross the boundary with these features. Use the external-store pattern for shared state instead.


🔹 27.5 Case study — React 17 admin + React 19.2 customer

Setup Acme acquired a competitor. Their admin runs React 17.0; the customer app is being rewritten on React 19.2. Both must be reachable under app.acme.example/*.

Decision narrative

  • Why MF over iframe: smoother UX (no cross-frame scrolling, auth-cookie pain).
  • Why React was not shared as singleton: 17 → 19 is two majors apart; shimming both at runtime is risky.
  • How the shared cart count was wired: Zustand store + useSyncExternalStore in both Reacts.

Outcome

  • Bundle size impact: +42 KB gzipped (the extra React copy).
  • Lighthouse delta: -2 points on the page that loads both.
  • Migration runway: 14 months estimated instead of “rewrite everything now” (which would have been 24+ months and probably abandoned).

The cost: 14 months of careful upgrades, with the mixed-version pattern as a stable middle state. The alternative — a full freeze and rewrite — wasn’t viable for the business.


🪤 Common Pitfalls

  1. Sharing React as singleton across incompatible versions — silent invariant failures.
  2. Passing a Context provider’s value across versions — it just won’t read.
  3. Rendering React 19 Suspense around a React 17 child — won’t suspend.
  4. Forgetting to unsubscribe event-bus listeners on React unmount — memory leaks.
  5. Letting both Reacts hydrate SSR markup — pick one; the other client-renders.
  6. Building dev with one strategy (multiple roots) and prod with another (MF) — divergence bugs.
  7. Treating the mixed-version setup as permanent — write down an end-date.

✅ Recap

  • Mixed React versions are a symptom of org reality, not a vanity choice.
  • Four isolation strategies; pick by boundary strength needed vs cost tolerated.
  • Share state through an external store, not React context.
  • Context, portals, Suspense don’t cross version boundaries.
  • Treat as a temporary state with a written end-date.

🔗 Further Reading

  • “Multiple Reacts on a page” — React team write-up.
  • https://module-federation.io/ → “Concept” → “Shared Dependencies”.
  • BroadcastChannel MDN reference.
  • Ch 26 (Module Federation), Ch 13 (Zustand / external stores).

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (5)
  1. 27.1 Why this happens in real companies
  2. 27.2 Isolation strategies — four options
  3. 27.3 Sharing state across React versions
  4. 27.4 What breaks across versions
  5. 27.5 Case study — React 17 admin + React 19.2 customer