The shared store is the host↔plugin contract: one federation singleton that carries the host's
values (hooks and helpers), its registries (contribution points) and an optional typed events
bus. The host declares it; plugins bind to it; TypeScript checks both sides against the same shape.
Host side — defineSharedStore#
import { defineSharedStore } from "@z0devs/mf-shell-kit";
import { NavRegistry, ComponentRegistry } from "@z0devs/mf-registry";
import { createEventsEmitter } from "@z0devs/mf-context";
import { useAuth } from "../auth";
import useGlobalStore from "./global";
import { contextualApiFetch } from "../clients/contextualApiFetch";
const events = createEventsEmitter<{
onAuthChange: { user: User | null };
onOrgChange: { orgId: string };
}>();
export const sharedStore = defineSharedStore({
values: { useAuth, useGlobalStore, contextualApiFetch },
registries: { nav: NavRegistry.api, components: ComponentRegistry.api },
features: { onboarding: true, router: true },
events,
});
export default sharedStore.useShellSharedStore; // ← what plugins bind to
export { sharedStore }; // ← host-side accessThe keys under values are yours, not the library's. useAuth, useGlobalStore and
contextualApiFetch here are just what this example's host happens to call them — the shipped
basic-shell example exposes useAuth and useOrg
instead. Plugins type against whatever names your shell publishes, so pick them once and treat them
as the contract.
What comes back:
| Member | Audience | Purpose |
|---|---|---|
sharedStore.values / .registries | Host | Direct access; registries are the full RegistryApi |
sharedStore.useShellSharedStore() | Plugin components | Read view — registries narrowed to PluginRegistryApi |
sharedStore.useInitProps(remoteName) | Plugin init | Same narrowed API, ids auto-prefixed per remote |
sharedStore.Provider | Host | Mounted for you by PluginRouteLoader |
sharedStore.apiVersion | Both | The semver plugins gate on |
sharedStore.events | Both | Typed emitter, shared across bundles |
Federation expose contract
The plugin bridge calls the default export of your shell/sharedStore expose as a hook. Export
sharedStore.useShellSharedStore as default — not the store object.
Plugin side — the self-initializing bridge#
import { createPluginStoreBridge } from "@z0devs/mf-plugin-kit";
import LocalContext from "./LocalContext";
const bridge = createPluginStoreBridge({
loadShellModule: () => import("shell/sharedStore"),
LocalContextFallback: LocalContext,
});
await bridge.ready;
export const { useShellSharedStore } = bridge;Three things are happening in those five lines:
- The load starts eagerly, at factory call — single-flight, so one import is shared by every consumer no matter how many components mount.
bridge.readynever rejects. Every failure path — the federated import throwing, a customisStandalone()throwing — binds the local fallback and resolves. That is what makes a top-levelawaitsafe: it can't poison the module graph.- Top-level await gates the module graph. This module (and every importer, including the
federated
./initexpose) finishes evaluating only once the store is bound, so components calluseShellSharedStore()synchronously — no provider, no HOC, no Suspense anywhere.
import { useRegistryItem } from "@z0devs/mf-plugin-kit";
const { values, registries } = useShellSharedStore();
const { user } = values.useAuth();
// Reactive: re-renders if the owning plugin is toggled off or on. `getItem` here would be correct on
// first paint and silently stale afterwards.
const widget = useRegistryItem(registries.components, "billing_mfe:InvoiceWidget");Bundler support for top-level await#
| Bundler | Status | Notes |
|---|---|---|
| Rspack | Verified | Async module graph propagates through MF exposes with no flags |
| Webpack 5 | Supported | Set experiments.topLevelAwait: true |
Vite (@module-federation/vite) | Verified | TLA compiles and the expose factory resolves, standalone and as a remote under an Rspack host — with the cross-bundler settings and the built output served, not the dev server |
One deadlock to avoid
The shell's sharedStore module must not import back into a remote. Two async module graphs waiting
on each other never settle, and the symptom is a shell that boots to a blank screen with no error.
If you can't use top-level await#
Gate rendering instead. Wrap the tree:
import { CombinedStoreProvider } from "./core/useShared";
export default function App() {
return (
<CombinedStoreProvider>
<MyMFE />
</CombinedStoreProvider>
);
}…or wrap a single federated entry point — useful for components you publish into the host's component registry, which other MFEs render outside your tree:
export const { useShellSharedStore, withCombinedStore } = bridge;
registries.components.addItem({
id: "DecisionAssistant",
component: withCombinedStore(DecisionAssistant, { LoadingFallback: <Skeleton /> }),
});CombinedStoreProvider is a thin gate over the same singleton: if ready already settled it renders
children synchronously — no loading flash, no duplicate fetch when several providers mount.
Typing the slice you actually use#
Declaring the whole host store as your plugin's dependency is over-coupling. SelectSharedStoreSlice
narrows it to the exact slots you read — and your standalone stub then has to satisfy that, so it
cannot drift:
import { createContext } from "react";
import type { SelectSharedStoreSlice } from "@z0devs/mf-plugin-kit";
import type useShellSharedStore from "shell/sharedStore";
export type Slice = SelectSharedStoreSlice<
ReturnType<typeof useShellSharedStore>,
{
values: {
useAuth: true; // the whole hook
useGlobalStore: "currentOrganization" | "currentProject"; // narrowed snapshot
};
registries: { components: true };
}
>;
// Stable references so every read returns the same object — see the note on `getItems` below.
const NO_ITEMS: never[] = [];
const NO_FLAGS: string[] = [];
const LocalContext = createContext<Slice>({
values: {
useAuth: (() => ({ user: { id: "u-stub", email: "dev@local" }, isLoading: false })) as Slice["values"]["useAuth"],
useGlobalStore: (() => ({
currentOrganization: { id: "stub", name: "Local Org" },
currentProject: { id: "stub", title: "Local Project" },
})) as Slice["values"]["useGlobalStore"],
},
registries: {
components: {
addItem() {},
removeItem() {},
getItem: () => undefined,
// Hoisted constant, NOT a fresh `[]`. This is the `getSnapshot` for `useRegistryItems`, and
// `useSyncExternalStore` requires a stable reference — a new array per call fails `Object.is` every
// render and loops until React throws "Maximum update depth exceeded".
getItems: () => NO_ITEMS,
getActiveFlags: () => NO_FLAGS,
subscribe: () => () => {},
} as Slice["registries"]["components"],
},
});
export default LocalContext;Read a value you didn't declare in the slice and it's a type error, not a production surprise.
Prefer failures at runtime too? createLocalStubContext returns a Proxy that throws
[@z0devs/mf-plugin-kit] missing local stub for "<key>" on any unstubbed access — see
Standalone development.
Lifecycle events#
// host
sharedStore.events.emit("onOrgChange", { orgId: nextOrg.id });
// plugin
import { useLifecycle } from "@z0devs/mf-shell-kit";
useLifecycle(events, "onOrgChange", ({ orgId }) => {
queryClient.invalidateQueries({ queryKey: ["org", orgId] });
});The emitter is typed end to end (createEventsEmitter<Events>), so an unknown key or a wrong payload
shape fails to compile. useListenerContext(bus, ["tick"]) from @z0devs/mf-context is the
re-render-on-event variant.
Choosing what belongs in values#
Rule of thumb
values is a public API with a version number. Add something only if you're willing to keep its
shape stable until the next major, and to write a migration note when you change it.
Good candidates: auth state, the tenant/org context, a fetch wrapper that injects that context, feature-flag reads, a toast/notification API, a design-system theme hook.
Poor candidates: your router instance (use the routes registry), raw HTTP clients per backend
(wrap them), anything whose type is any, internal stores you plan to refactor.