State & event bus
Two primitives cover almost every cross-remote communication pattern: a typed event bus (@jorvel/event-bus) and a singleton store registry (@jorvel/state). Both are pinned to globalThis so duplicate bundles still observe the same instance — the federation share-scope is the fast path, globalThis is the safety net.
Picking the right primitive
- Event bus — fire-and-forget cross-remote signals (analytics ping, cart updated, user logged in).
- SimpleStore — a single value with subscribers (current theme, locale, feature flags).
- Store + reducer — non-trivial state machines with auditable actions (cart, multi-step forms).
- Selectors — derive view-models from any store without recomputing.
Event bus
import { getEventBus } from '@jorvel/event-bus';
interface Events {
'user:login': { userId: string };
'cart:updated': { count: number };
'theme:changed': 'light' | 'dark';
}
const bus = getEventBus<Events>();
// Subscribe (returns unsubscribe)
const off = bus.on('cart:updated', (p) => console.log(p.count));
// Subscribe + replay last value (great for late-mounting subscribers)
bus.on('theme:changed', applyTheme, { replay: true });
// Listen exactly once
bus.once('user:login', (p) => audit.track('first-login', p));
// Wildcard — every event (logging / devtools)
const offAny = bus.onAny((event, payload) => devtools.push({ event, payload }));
// Fire
bus.emit('cart:updated', { count: 3 });
// Tear down
off();
offAny();
bus.clear('cart:updated'); // wipe one event
bus.clear(); // wipe everything (tests)bus is a singleton — any remote that imports @jorvel/event-bus shares the same instance because the package is declared as a singleton in federation config. A handler that throws is caught (errorHandler) so a single bad subscriber cannot abort delivery to the rest.
Simple store
import { getSimpleStore } from '@jorvel/state';
const auth = getSimpleStore<{ user: User | null }>('auth', { user: null });
auth.subscribe((s) => render(s));
auth.set({ user: { id: '1', email: 'x@y.z' } });Redux-style store
import { getStore } from '@jorvel/state';
type State = { count: number };
type Action = { type: 'inc' } | { type: 'set'; value: number };
const store = getStore<State, Action>('counter', {
initial: { count: 0 },
reducer: (s, a) => {
switch (a.type) {
case 'inc': return { count: s.count + 1 };
case 'set': return { count: a.value };
}
},
});
store.dispatch({ type: 'inc' });Atoms (Jotai-style)
For fine-grained, bottom-up state, use atom + derivedAtom. Atoms are module-scoped values you import; React bindings (useAtom / useAtomValue / useSetAtom) live in @jorvel/state/react.
import { atom, derivedAtom } from '@jorvel/state';
export const count = atom(0);
export const doubled = derivedAtom([count], ([c]) => c * 2); // recomputes on count change
count.set(5); // or count.set((p) => p + 1)
doubled.get(); // 10import { useAtom, useAtomValue, useSetAtom } from '@jorvel/state/react';
import { count, doubled } from './atoms.js';
function Counter() {
const [n, setN] = useAtom(count);
const d = useAtomValue(doubled); // re-renders only when doubled changes
return <button onClick={() => setN((p) => p + 1)}>{n} → {d}</button>;
}
function ResetButton() {
const setN = useSetAtom(count); // setter only — does NOT re-render on change
return <button onClick={() => setN(0)}>reset</button>;
}derivedAtom takes explicit dependencies — the graph stays static and predictable, and dep subscriptions attach only while something is listening.
Memoized selectors
createSelector composes input selectors and caches the last result. Use it to derive view models that should only recompute when their dependencies change.
import { createSelector, createStructuredSelector, shallowEqual, createSelectorWith } from '@jorvel/state';
const selectUser = (s: AppState) => s.user;
const selectCart = (s: AppState) => s.cart;
const selectCartSummary = createSelector(
selectCart,
(cart) => ({ count: cart.items.length, total: cart.items.reduce((a, b) => a + b.price, 0) }),
);
// Custom equality (shallow) — useful when a selector returns a fresh object.
const selectDashboard = createSelectorWith(
{ equalityFn: shallowEqual },
selectUser,
selectCart,
(u, c) => ({ name: u.name, count: c.items.length }),
);
// Structured selectors return an object with the named results.
const selectHeader = createStructuredSelector({
user: selectUser,
cartCount: (s) => s.cart.items.length,
});Store middleware
Compose middleware around the store to log, throttle, persist, or defer async work. thunkMiddleware turns function actions into (dispatch, getState) callbacks; loggerMiddleware prints every dispatch; persistenceMiddleware writes the latest state to your storage adapter with optional throttling.
import {
createStoreWithMiddleware,
thunkMiddleware,
loggerMiddleware,
persistenceMiddleware,
} from '@jorvel/state';
const store = createStoreWithMiddleware(
{ user: null, count: 0 },
reducer,
[
thunkMiddleware(),
loggerMiddleware({ label: 'shell', printState: true }),
persistenceMiddleware({
save: (s) => localStorage.setItem('app', JSON.stringify(s)),
throttleMs: 250,
}),
],
);
// Thunk
store.dispatch(({ dispatch }) => {
fetch('/me')
.then((r) => r.json())
.then((user) => dispatch({ type: 'user.set', user }));
});Persistence round-trips custom serializers
persistStore / persistSimpleStore write an envelope of { v, state } where state is the serialized payload string, so a custom serialize/deserializepair (superjson, Map, Date) round-trips exactly. On detach they flush any pending debounced write, and they dedupe the hydration echo so rehydrating doesn't re-trigger a save. migrate runs only when the stored version is below the current one — never on a downgrade.Cross-tab sync
syncStore / syncSimpleStore (from @jorvel/state/sync) mirror state across same-origin tabs over a BroadcastChannel. Each local change is broadcast; incoming updates are applied without re-broadcast (origin-tagged to avoid echo loops). It is a no-op where BroadcastChannel is unavailable (SSR), and pairs with persistence — persist rehydrates a fresh tab, sync keeps open tabs in step.
import { getSimpleStore } from '@jorvel/state';
import { syncSimpleStore } from '@jorvel/state/sync';
const cart = getSimpleStore('cart', { items: [] });
const stop = syncSimpleStore(cart, { channel: 'cart' }); // returns a detach fn
// cart.set(...) in one tab now updates every other tab on the 'cart' channel.Schema-validated events
Attach validators to the event bus to reject malformed payloads at the source. Any object with a parse(input) method works (Zod, Valibot, ArkType, custom).
import { getEventBus, attachSchemaRegistry } from '@jorvel/event-bus';
import { z } from 'zod';
interface Events {
'cart:add': { sku: string; qty: number };
}
const bus = getEventBus<Events>();
attachSchemaRegistry(bus, {
'cart:add': z.object({ sku: z.string().min(1), qty: z.number().int().positive() }),
}, { onInvalid: 'throw', log: (event, err) => console.warn(event, err) });
bus.emit('cart:add', { sku: 'A', qty: 2 }); // ok
bus.emit('cart:add', { qty: 1 } as never); // throws — sku missingFeature flags
InMemoryFlags is a built-in provider for local dev / tests. fromVendor() wraps any duck-typed SDK (LaunchDarkly, Flagsmith, Statsig, …) into the common FeatureFlagAdapter interface. Register once at boot, then read from anywhere with isFeatureEnabled() / featureVariation().
import {
InMemoryFlags,
setFeatureFlags,
isFeatureEnabled,
featureVariation,
fromVendor,
} from '@jorvel/runtime';
// Local / tests
setFeatureFlags(new InMemoryFlags({
flags: { newOnboarding: true, theme: 'dark' },
overrides: { 'u-vip': { newOnboarding: true } },
}));
// Vendor (LaunchDarkly example — duck-typed, no LD dep needed)
// const client = LD.init(process.env.LD_KEY!);
// setFeatureFlags(fromVendor(client, {
// toClientContext: (ctx) => ({ kind: 'user', key: ctx?.userId ?? 'anon' }),
// }));
if (isFeatureEnabled('newOnboarding', { userId: user.id })) renderOnboarding();
const theme = featureVariation('theme', 'light', { userId: user.id });Cross-tab sync
Mirror emissions across same-origin tabs with a BroadcastChannel adapter. Echoes from the originating tab are suppressed automatically.
import { connectBroadcast } from '@jorvel/event-bus';
const conn = connectBroadcast(bus, {
channelName: 'jorvel:cart',
filter: (event) => event !== 'cart:internal', // keep some events tab-local
});
// later
conn.disconnect();SSR hydration
Serialize state on the server, rehydrate on the client. The serialization helper escapes </script> sequences and accepts a CSP nonce so a strict policy still admits the payload.
// server.ts
import { serializeState } from '@jorvel/ssr';
import { buildCsp, generateNonce } from '@jorvel/security';
const nonce = generateNonce();
const initial = await loadInitialState(request);
const head = serializeState(initial, { key: 'app', nonce });
response.setHeader('Content-Security-Policy', buildCsp({ nonce }));
const html = baseHtml.replace('</head>', head + '</head>');
// bootstrap.tsx
import { hydrateState, clearHydratedState } from '@jorvel/ssr';
const state = hydrateState<InitialState>('app');
if (state) primeStore(state);
clearHydratedState('app'); // free the <script> tag once consumedRecipes
Sharing auth between host and remote
import { getSimpleStore } from '@jorvel/state';
import { getEventBus } from '@jorvel/event-bus';
export interface Session { userId: string; roles: string[] }
export const sessionStore = getSimpleStore<Session | null>('session', null);
// Mirror state to the bus so latecomers can pick it up.
const bus = getEventBus<{ 'auth:session': Session | null }>();
sessionStore.subscribe((s) => bus.emit('auth:session', s));Time-travel debugging in dev
import { getStore } from '@jorvel/state';
const counter = getStore('counter', { count: 0 }, reducer);
if (process.env.NODE_ENV !== 'production') {
const history: unknown[] = [counter.getState()];
counter.subscribe((s) => history.push(structuredClone(s)));
(window as any).__store = { counter, history, rewind: (i: number) => counter.replaceState(history[i] as never) };
}Resetting between tests
import { _resetStore, _resetSimpleStore } from '@jorvel/state';
import { _resetEventBus } from '@jorvel/event-bus';
afterEach(() => {
_resetStore();
_resetSimpleStore();
_resetEventBus();
});RSC-compatible server store
Populate a store on the server, dehydrate() it into the HTML, and hydrate() on the client so the first render matches — no refetch, no flash.
import { createServerStore, dehydrateAll, serializeState, createHydratedStore } from '@jorvel/state';
// server
const cart = createServerStore({ items: [] });
const html = `<script>window.__JORVEL_STATE__ = ${serializeState(dehydrateAll({ cart }))}</script>`;
// client (seeds from the embedded snapshot, else the fallback)
const cart = createHydratedStore('cart', { items: [] });The server side is hook-free (safe in RSC / edge handlers); the client gets get/set/subscribe. serializeState escapes </script>.
