modern-react-spa

Chapter 29

State Management Complexity at Scale

State at scale — per-remote stores vs shared store in federated SPAs, cache coherence across micro-frontends, and the shadow Redux anti-pattern.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can decide between per-remote stores and a shared store in a federated SPA, design cache-coherence policies across micro-frontends, and recognise the “shadow Redux” anti-pattern before it consumes a sprint.

🧭 Prerequisites — Ch 13 (global state), Ch 14 (server cache), Ch 26 (Module Federation), Ch 27 (mixed React versions).


🔹 29.1 Per-remote stores vs shared store

In a federated SPA (Ch 26), each remote is its own deployable. Where does global state live?

Option A — per-remote stores

Each remote has its own Zustand store. Remotes don’t share state directly; they communicate via events / URL / shared HTTP cache.

Pros: clean ownership; no cross-team coupling; remotes can change their state shape independently. Cons: duplication where genuinely shared state exists (cart, user profile, theme).

Option B — shared store hosted in the host

The host owns the store; remotes import it as a shared dep (shared: { 'zustand-store-pkg': { singleton: true } }).

Pros: one source of truth for shared concerns. Cons: the store becomes a coordination point; changing its shape requires every remote to bump.

Option C — hybrid (the real-world answer)

  • Per-remote stores for remote-specific UI state.
  • One small shared store, owned by a platform team, for genuinely cross-cutting concerns: current user, cart count, current tenant, theme.
  • Server data → TanStack Query in each remote, with a shared query cache (or independent caches if the data domains are disjoint).

Resist the urge to put “everything that might be shared” in the shared store. Once it’s there, removing it is political.


🔹 29.2 Cache coherence across micro-frontends

The hard problem: remote A mutates an invoice; remote B is showing the invoices list. Does B refetch?

Three coherence levels:

Strong — shared TanStack Query cache

A single QueryClient lives in the host; all remotes import it (as a singleton dep). Invalidation in any remote invalidates for all.

// host
export const qc = new QueryClient(/* … */);
// shared: { '@acme/query': { singleton: true } } in MF config
// any remote
import { qc, useInvoices } from '@acme/query';
qc.invalidateQueries({ queryKey: ['invoices'] });

Pros: consistent data everywhere. Cons: the QueryClient becomes critical infrastructure; bugs here are cross-remote outages.

Eventual — event-bus invalidation

Each remote has its own QueryClient. They listen on a shared event bus; on invoice-updated, each remote’s listener invalidates its own queries.

// in each remote
useEffect(() => on('invoice-updated', () => qc.invalidateQueries({ queryKey: ['invoices'] })), []);

Pros: loose coupling. Cons: consumers must remember to publish events; missing-publish bugs are silent.

Weak — short staleTime

Set staleTime low enough that refetch happens “soon enough.” Acceptable for non-critical data (display-only stats, autocomplete results).

const qc = new QueryClient({ defaultOptions: { queries: { staleTime: 5_000 } } });

Pros: no coordination needed. Cons: wasted requests; “stale” window during which users see wrong data.

Picking by data type:

Data typeCoherence level neededPattern
User identityStrongShared cache
Cart contentsStrongShared cache or shared store
List of invoicesEventualEvent bus
Tenant configStrongShared cache
Autocomplete resultsWeakShort staleTime
Real-time monitoringStrong + WebSocketPush, not pull

🔹 29.3 The “shadow Redux” anti-pattern

The pattern: a team without explicit global state ends up reinventing it badly.

Symptoms:

  • Many useEffects that read from one component and write to another via a useRef global.
  • A “global event bus” with no schema or types, used for everything.
  • “Sync hooks” that copy state between contexts on every render.
  • A window.__APP_STATE object that components both read and write directly.

Root cause: the team avoided picking a state library (Zustand, Redux, whatever) because “we don’t really need one.” Then they did need one, and built a worse one.

Fix: name the moment you have global state. Pick a library. Migrate the ad-hoc patterns to it over a sprint.

// ❌ shadow Redux
// componentA.tsx
window.__cart = window.__cart ?? [];
window.__cart.push(item);
window.dispatchEvent(new CustomEvent('cart-changed'));

// componentB.tsx
const [cart, setCart] = useState<Item[]>(window.__cart ?? []);
useEffect(() => {
  const h = () => setCart([...window.__cart]);
  window.addEventListener('cart-changed', h);
  return () => window.removeEventListener('cart-changed', h);
}, []);

// ✅ Zustand (Ch 13)
const useCart = create<CartState>((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
}));

// In any component:
const items = useCart((s) => s.items);

The ✅ version is shorter, typed, devtools-introspectable, testable.


🔹 29.4 The decision flow for “what kind of global state do I need at scale?”

                ┌─────────────────────────────────────────┐
                │  Is the value server-owned?             │
                └─────────────────────────────────────────┘

            ┌──────────┴──────────┐
           Yes                   No
            │                     │
            ▼                     ▼
    TanStack Query        ┌──────────────────────────┐
    (Ch 14)               │ Cross-remote concern?    │
                          └──────────────────────────┘

                       ┌──────────┴──────────┐
                      Yes                   No
                       │                     │
                       ▼                     ▼
              Shared store (29.1) /    Per-remote Zustand
              event bus (29.2)         (Ch 13)

Most “scale” problems with state turn out to be misclassification. The Ch 12 taxonomy and this flow catch 80 %.


🪤 Common Pitfalls

  1. One giant shared store for everything → cross-team coordination tax.
  2. Many separate query caches when one would do → coherence bugs.
  3. Event bus without typed contracts → ad-hoc string-keyed chaos.
  4. Reinventing Redux (“just a few useRefs and useEffects”) → shadow Redux.
  5. Forgetting WebSocket invalidation when adding real-time → stale data after pushes.
  6. Per-remote stores that secretly copy each other’s data → multiple sources of truth.

✅ Recap

  • Hybrid: per-remote stores + small shared store for cross-cutting concerns.
  • Cache coherence in three levels — strong (shared cache), eventual (event bus), weak (short staleTime). Pick per data type.
  • Shadow Redux is real; name the moment you have global state and pick a tool.
  • Most “state at scale” problems are misclassification; revisit Ch 12.

🔗 Further Reading

  • Ch 12 (four kinds of state), Ch 13 (Zustand), Ch 14 (TanStack Query), Ch 26 (Module Federation).
  • TKDodo — “Don’t React” series; some posts on multi-app cache strategies.

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 (4)
  1. 29.1 Per-remote stores vs shared store
  2. 29.2 Cache coherence across micro-frontends
  3. 29.3 The “shadow Redux” anti-pattern
  4. 29.4 The decision flow for “what kind of global state do I need at scale?”