Using Zustand for Cross-Micro-Frontend State #
You want host and remotes to read and write the same state without prop drilling or wiring a heavyweight store into every container — a single shared Zustand store, exposed once and deduplicated as a Module Federation singleton, does exactly that.
A full Redux tree synchronized across containers is powerful but heavy: reducers, middleware, devtools wiring, and an @reduxjs/toolkit singleton in every remote. If your shared surface is a handful of cross-cutting concerns — current user, theme, cart count, feature flags — a vanilla Zustand store is a fraction of the bytes and ceremony. The trick is making sure there is exactly one instance of that store at runtime, no matter how many remotes import it. This guide shows the store, the host that exposes it, a React remote, and a non-React (vanilla) remote subscriber, plus how to verify a single instance and fix the failure modes that quietly break sync.
This is a lighter sibling of Synchronizing Redux Across Micro-Frontends. Pick Redux when you need time-travel debugging, strict action audit trails, or large normalized entity caches; pick the approach below when you mostly need a small, shared, reactive bag of state.
Prerequisites #
- Zustand 4.5+ (the
zustand/vanillaandzustand/reactentry points used below are stable from 4.4 onward; 5.x works with the same imports). - Webpack 5 with
ModuleFederationPlugin, or@module-federation/enhanced. The samesharedsemantics apply to the Vite federation plugin. - A working host/remote setup. If
sharedsingletons are new to you, read Configuring Shared Singletons to Deduplicate React first — the exact same mechanism keeps your store single. - React 18 in remotes that use the React bindings; the vanilla remote needs no framework.
Step 1 — Create a vanilla store in a shared module #
Build the store with createStore from zustand/vanilla, not create from zustand/react. A vanilla store is framework-agnostic: it exposes getState, setState, and subscribe, which is exactly what a non-React remote needs. React bindings are layered on top in Step 3.
// shared/appStore.ts (lives in the host, exposed via Module Federation)
import { createStore } from 'zustand/vanilla';
export interface AppState {
userId: string | null;
theme: 'light' | 'dark';
cartCount: number;
setUser: (id: string | null) => void;
toggleTheme: () => void;
addToCart: (n?: number) => void;
}
export const appStore = createStore<AppState>((set) => ({
userId: null,
theme: 'light',
cartCount: 0,
setUser: (id) => set({ userId: id }),
toggleTheme: () =>
set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
addToCart: (n = 1) => set((s) => ({ cartCount: s.cartCount + n })),
}));
// Re-export the raw store so any container can call getState/subscribe/setState.
export type AppStore = typeof appStore;
Keep this module side-effect-light: it creates the store on first import and nothing else. Because it will be a singleton (Step 2), that “first import” happens exactly once.
Step 2 — Expose the store and mark it (and Zustand) as singletons #
Two things must be singletons: the zustand package, and the store module itself. Sharing only zustand is not enough — if each remote bundles its own copy of appStore.ts, each gets its own createStore call and its own state. Add the store module to shared so the federation runtime serves one instance to everyone.
// webpack.config.js (Host)
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
filename: 'remoteEntry.js',
exposes: {
'./appStore': './shared/appStore.ts',
},
shared: {
zustand: { singleton: true, requiredVersion: '^4.5.0' },
// The store module itself MUST be a singleton, or remotes
// that bundle their own copy will never see host updates.
'./shared/appStore': {
singleton: true,
// import: false on remotes -> they consume the host's copy
},
},
}),
],
resolve: { extensions: ['.ts', '.tsx', '.js'] },
};
On each remote, reference the host as a remote and share zustand as a singleton too:
// webpack.config.js (Remote)
new ModuleFederationPlugin({
name: 'reactRemote',
remotes: {
host: 'host@http://localhost:3000/remoteEntry.js',
},
shared: {
zustand: { singleton: true, requiredVersion: '^4.5.0' },
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
});
Remotes import the store from the host (host/appStore), so they automatically get the one instance the host created.
Step 3 — Consume it in a React remote with selectors #
Wrap the vanilla store with useStore from zustand/react so React components subscribe with selectors. A selector returns only the slice a component cares about, so the component re-renders only when that slice changes — critical when many remotes share one store.
// reactRemote/src/CartBadge.tsx
import { useStore } from 'zustand/react';
// Imported from the host via Module Federation:
import { appStore } from 'host/appStore';
export function CartBadge() {
// Narrow selector -> re-renders only when cartCount changes.
const cartCount = useStore(appStore, (s) => s.cartCount);
const addToCart = useStore(appStore, (s) => s.addToCart);
return (
<button onClick={() => addToCart(1)} aria-label="cart">
Cart ({cartCount})
</button>
);
}
For object/array slices, pass useShallow to avoid re-rendering on every setState:
import { useStore } from 'zustand/react';
import { useShallow } from 'zustand/react/shallow';
import { appStore } from 'host/appStore';
export function UserChip() {
const { userId, theme } = useStore(
appStore,
useShallow((s) => ({ userId: s.userId, theme: s.theme })),
);
return <span data-theme={theme}>{userId ?? 'guest'}</span>;
}
The host consumes the store the same way — it already has the module locally, so it just imports appStore directly and calls useStore(appStore, selector).
Step 4 — Subscribe from a non-React (vanilla) remote #
A remote built in Angular, Vue, Svelte, or plain DOM does not need the React bindings at all. It uses the vanilla API: getState for a one-shot read, setState-style actions to write, and subscribe to react to changes. This is the same decoupled posture you would use with event bus patterns for decoupled apps — except the store is the source of truth, not just a transport.
// vanillaRemote/src/themeWidget.ts
import { appStore } from 'host/appStore';
export function mountThemeWidget(root: HTMLElement) {
const button = document.createElement('button');
root.appendChild(button);
const render = (theme: string) => {
button.textContent = `Theme: ${theme}`;
button.dataset.theme = theme;
};
// Initial paint from current state.
render(appStore.getState().theme);
// Write back into the shared store.
button.addEventListener('click', () => appStore.getState().toggleTheme());
// Subscribe -> fires on every change; diff the slice you care about.
const unsubscribe = appStore.subscribe((state, prev) => {
if (state.theme !== prev.theme) render(state.theme);
});
// Always return teardown so unmounting the remote stops the listener.
return unsubscribe;
}
Because subscribe fires on any state change, compare the slice you care about (state.theme !== prev.theme) and bail early — that is the vanilla equivalent of a React selector.
Step 5 — Persist and slice per domain #
Keep the cross-cutting store small. For state that should survive reloads, wrap the creator with persist and a partialize so you only write the durable slices. For independent concerns, prefer several small stores over one mega-store so unrelated updates never wake unrelated subscribers.
// shared/appStore.ts (with persistence)
import { createStore } from 'zustand/vanilla';
import { persist, createJSONStorage } from 'zustand/middleware';
export const appStore = createStore<AppState>()(
persist(
(set) => ({
userId: null,
theme: 'light',
cartCount: 0,
setUser: (id) => set({ userId: id }),
toggleTheme: () =>
set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
addToCart: (n = 1) => set((s) => ({ cartCount: s.cartCount + n })),
}),
{
name: 'mfe-app-state',
storage: createJSONStorage(() => localStorage),
// Persist only durable, non-sensitive fields.
partialize: (s) => ({ theme: s.theme }),
},
),
);
Never persist auth tokens here — keep those in the secure bridge described in Synchronizing Redux Across Micro-Frontends. If your sharing need is really “pass React context down through remotes,” compare this with Using Shared Context Providers Across Remotes; a store wins when writers and readers live in different containers.
Verification #
Confirm two things: there is exactly one store instance, and a write in one container is visible everywhere instantly.
One instance. Stamp the store and read the stamp from every container’s console:
// shared/appStore.ts
(appStore as unknown as { __id: string }).__id ??=
Math.random().toString(36).slice(2);
console.log('[appStore] instance', (appStore as { __id: string }).__id);
Load the page and check the console — the host log and every remote log must print the same __id. Two different ids mean two stores (see troubleshooting below).
Instant propagation. From the host’s devtools console, mutate the store and watch the remotes update without a reload:
// In the host page console:
window.__appStore = appStore; // expose for the test only
__appStore.getState().addToCart(3);
__appStore.getState().toggleTheme();
The React remote’s Cart (n) badge should increment by 3 and the vanilla widget’s theme label should flip — both immediately. A Jest/Vitest assertion proves the contract without a browser:
import { appStore } from '../shared/appStore';
test('updates are visible through getState', () => {
appStore.getState().addToCart(2);
expect(appStore.getState().cartCount).toBe(2);
});
Troubleshooting #
Two store instances, no sync.
Symptom: host and remote print different __ids; updates in one are invisible in the other. Diagnosis: the store module is not actually shared — usually because only zustand was marked singleton and each remote bundled its own copy of appStore.ts, or a remote imported it by relative path (../shared/appStore) instead of from the host (host/appStore). Fix: add the store module to shared with singleton: true on the host (Step 2), import it everywhere as host/appStore, and make sure no remote also lists it in its own exposes.
Selector re-render storms.
Symptom: a component re-renders on every unrelated setState, tanking frame rate as more remotes write to the store. Diagnosis: the selector returns a new object/array each call (e.g. (s) => ({ a: s.a, b: s.b })), so the default Object.is equality always sees a change. Fix: select primitives directly, or wrap object selectors in useShallow. Split hot, high-frequency state into its own store so it never wakes cold subscribers.
Stale closure reading old state.
Symptom: a callback or setInterval captured cartCount once and keeps using the old value after the store changed. Diagnosis: you read state into a variable and closed over it, instead of reading at call time. Fix: read fresh state inside the callback with appStore.getState().cartCount, and use the functional set((s) => ...) form for updates that depend on current state.
SSR hydration mismatch.
Symptom: React warns about server/client markup mismatch, or persisted state flashes the wrong value on first paint. Diagnosis: the store read localStorage (or differed between server and client) during the initial render, so server HTML and the first client render disagree. Fix: render the server-default value first, then apply persisted/store state in an effect after mount (useEffect) so the first client render matches the server; with the persist middleware, gate UI on appStore.persist.hasHydrated() before showing persisted slices.