Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

The shared store

How host values and registries reach a plugin — defineSharedStore, the self-initializing bridge, and typed slices.

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#

src/store/sharedStore.tsx
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 access

The 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:

MemberAudiencePurpose
sharedStore.values / .registriesHostDirect access; registries are the full RegistryApi
sharedStore.useShellSharedStore()Plugin componentsRead view — registries narrowed to PluginRegistryApi
sharedStore.useInitProps(remoteName)Plugin initSame narrowed API, ids auto-prefixed per remote
sharedStore.ProviderHostMounted for you by PluginRouteLoader
sharedStore.apiVersionBothThe semver plugins gate on
sharedStore.eventsBothTyped 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#

src/core/useShared.tsx
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:

  1. The load starts eagerly, at factory call — single-flight, so one import is shared by every consumer no matter how many components mount.
  2. bridge.ready never rejects. Every failure path — the federated import throwing, a custom isStandalone() throwing — binds the local fallback and resolves. That is what makes a top-level await safe: it can't poison the module graph.
  3. Top-level await gates the module graph. This module (and every importer, including the federated ./init expose) finishes evaluating only once the store is bound, so components call useShellSharedStore() 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#

BundlerStatusNotes
RspackVerifiedAsync module graph propagates through MF exposes with no flags
Webpack 5SupportedSet experiments.topLevelAwait: true
Vite (@module-federation/vite)VerifiedTLA 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:

src/core/LocalContext.ts
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.

Next#