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
| Strategy | Boundary strength | DX cost | Perf hit | Use when… |
|---|---|---|---|---|
| iframe | Process-level | High | High | Truly untrusted or legacy black-box code |
| Shadow DOM | DOM-level | Medium | Low | Style/scope isolation, same JS realm |
| Multiple roots | None (same realm) | Low | Low | Versions are close enough (18 + 19) |
| Module Federation | Module-graph | Medium | Low | Two 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:
- Context —
Contextobject 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 +
useSyncExternalStorein 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
- Sharing
Reactas singleton across incompatible versions — silent invariant failures. - Passing a Context provider’s value across versions — it just won’t read.
- Rendering React 19 Suspense around a React 17 child — won’t suspend.
- Forgetting to unsubscribe event-bus listeners on React unmount — memory leaks.
- Letting both Reacts hydrate SSR markup — pick one; the other client-renders.
- Building dev with one strategy (multiple roots) and prod with another (MF) — divergence bugs.
- 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”.
BroadcastChannelMDN 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.