Multi-Store Coordination
Coordinate independent stores through an event bus without coupling them directly.
Multi-Store Coordination
A single store works well for a single domain. When two domains need to communicate — orders trigger payments, payments trigger fulfillment — coupling them into one store creates a tangled state object. The event bus provides a clean boundary: each store manages its own state, and events flow between them.
When to split stores
| Signal | Approach |
|---|---|
| State fields belong to the same domain (user profile, avatar, preferences) | Single store |
| Two features share no state but react to each other's events | Separate stores, connected via event bus |
| A feature has grown beyond 5-6 command handlers | Consider splitting by subdomain |
| Components from different pages read overlapping state | Single store with targeted selectors |
The threshold is not about size — it is about ownership. If two groups of state fields change independently and are consumed by different parts of the UI, they are candidates for separate stores.
Connecting stores with EventBus
createEventBus() returns a bus that listens to every event emitted by connected stores and lets you route them:
import { createEventBus, createCommand } from "@naikidev/commiq";
import { orderStore, OrderEvent } from "./order";
import { paymentStore } from "./payment";
const bus = createEventBus();
bus.connect(orderStore);
bus.connect(paymentStore);
bus.on(OrderEvent.Validated, (event) => {
paymentStore.queue(
createCommand("payment:process", {
orderId: event.data.orderId,
amount: event.data.total,
}),
);
});
export { bus };Each .on() call registers a listener for a specific event definition. When any connected store emits that event, the handler fires.
Unidirectional event flow
The strongest multi-store architectures are pipelines: events flow in one direction, and no store queues commands back to an upstream store.
Each store emits what happened. The bus decides who should react. No store imports another store directly.
Avoiding circular chains
A circular chain — store A emits an event that queues a command on store B, which emits an event that queues a command back on store A — creates an infinite loop. Commiq does not detect this automatically.
If store A reacts to an event from store B, and store B reacts to an event from store A, the system will loop indefinitely. Design flows as directed acyclic graphs. If you need bidirectional communication, introduce a third store or use a terminal event that breaks the cycle.
Signs of a circular chain during development:
- The browser tab freezes after queuing a single command
- Devtools show unbounded event growth
flush()never resolves in tests
To break a cycle, remove the return path. If store B needs to notify store A that processing is complete, have the component or a dedicated coordinator read from store B's state instead of routing an event back.
Initialization and teardown
Create the bus in a dedicated module. Import that module once at application startup:
import "./bus"; // Executes bus setup, connects stores
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);For cleanup (relevant in tests or micro-frontends), call disconnect:
bus.disconnect(orderStore);
bus.disconnect(paymentStore);Full example: order pipeline
A four-store pipeline where each store handles one domain:
Each store defines its own events and handlers following the standard async loading states pattern. The key detail is the events each store emits — those are what the bus routes:
// Each store exports its domain events
export const OrderEvent = {
Validated: createEvent<{ orderId: string; total: number }>("order:validated"),
};
export const PaymentEvent = {
Completed: createEvent<{ orderId: string; transactionId: string }>("payment:completed"),
Failed: createEvent<{ orderId: string; reason: string }>("payment:failed"),
};
export const FulfillmentEvent = {
Shipped: createEvent<{ orderId: string; trackingCode: string }>("fulfillment:shipped"),
};Wire them together in the bus:
import { createEventBus, createCommand } from "@naikidev/commiq";
import { orderStore, OrderEvent } from "./stores/order";
import { paymentStore, PaymentEvent } from "./stores/payment";
import { fulfillmentStore, FulfillmentEvent } from "./stores/fulfillment";
import { notificationStore } from "./stores/notification";
const bus = createEventBus();
bus.connect(orderStore);
bus.connect(paymentStore);
bus.connect(fulfillmentStore);
bus.connect(notificationStore);
bus.on(OrderEvent.Validated, (event) => {
paymentStore.queue(
createCommand("payment:process", {
orderId: event.data.orderId,
amount: event.data.total,
}),
);
});
bus.on(PaymentEvent.Completed, (event) => {
fulfillmentStore.queue(
createCommand("fulfillment:ship", {
orderId: event.data.orderId,
transactionId: event.data.transactionId,
}),
);
});
bus.on(FulfillmentEvent.Shipped, (event) => {
notificationStore.queue(
createCommand("notification:send", {
orderId: event.data.orderId,
trackingCode: event.data.trackingCode,
}),
);
});
export { bus };Each store is independently testable. The bus file is the only place that knows the full flow. See testing for how to test multi-store setups with flush().