I recommend checking the entire example. The article is just an explanation why it's designed that way.
Trigger, Store, Repeat: Wiring React State the Event-Driven Way
Most React apps start the same way: a useState here, a useReducer there, maybe a Context for the "global" stuff. It works fine until it doesn't - until a button click needs to trigger an API call, which needs to update three unrelated parts of the UI, which need to notify another module entirely. Suddenly your component is doing orchestration, data fetching, and rendering all at once, and nobody wants to touch it anymore.
This is the problem an event-driven architecture combined with a store and React Context is built to solve. It sounds like three separate concepts bolted together, but in practice they form one coherent pipeline: events describe what happened, a store holds the resulting state, and Context delivers that state to the component tree without prop drilling.
Everything below is illustrated with real, working code from user-profile-setup inside apps/romantic-app - a step-by-step wizard that walks a user through setting up their relationship profile. You can browse all of it in one place: full example - every file path mentioned below lives under that folder, so this is the only link you'll need.
The Problem: When Context Alone Isn't Enough
React Context is great at solving one problem: passing data down without threading props through every level. It is not, by itself, a state management solution. If you put a raw useState value in a Context provider, every consumer re-renders on every change, and you still have no clear place to put "what happens when the user does X" logic.
The missing piece is a way to separate three concerns that tend to get mixed together in a typical component:
- What happened (the user clicked "start", a config finished loading, a step form was submitted).
- What should happen as a result (call an API, run validation, advance to the next step).
- What the UI should show now (the current state, derived from everything that has happened so far).
An event bus handles the first two. A store handles the third. Context is just the delivery mechanism that connects both to your components.
Keep in mind. Anti-pattern might be too big a word. Sometimes just be pragmatic, but I'm mostly talking about apps that are more complex than todo apps. In these, nobody needs the things I'm presenting today.
Anatomy of the Pattern: Bus, Store, Facade, and Context
The Event Bus: Triggers, Tasks, Facts, and Effects
At the center of the pattern is an event bus - a single stream that every part of the module can publish to and subscribe from. In gon-stack it lives in a small, reusable library: apps/romantic-app/src/libs/eda/index.tsx. It wraps an RxJS Subject, which gives you operators like filter and map for free, and it enforces a naming convention so events never turn into an unreadable soup of ad-hoc strings:
[TRIGGER]_*- something the user or the outside world did.[TASK]_*- work that needs to happen in response.[FACT]_*- something that is now true and should update state.[EFFECT]_*- a side effect that isn't state, like logging or analytics.
A module declares its own event union against these four shapes, and instantiates the bus against it. user-profile-setup/domain/events.ts plus user-profile-setup/core/bus.ts:
export type Event =
| TriggerEvent<'[TRIGGER]_INIT'>
| TriggerEvent<'[TRIGGER]_START'>
| TriggerEvent<'[TRIGGER]_PREV'>
| TriggerEvent<'[TRIGGER]_NEXT', Answers>
| TriggerEvent<'[TRIGGER]_EDIT_ANSWERS'>
| TriggerEvent<'[TRIGGER]_SAVE_ANSWERS'>;
export const createBus = () => eda<Event>();
export type Bus = ReturnType<typeof createBus>;
This convention alone solves a surprising amount of confusion: anyone reading an event name immediately knows whether it is an intent, a side effect in progress, a completed fact, or a UI-only effect.
The Store: A Single Source of Truth
Facts are only useful if something durable reacts to them. That is the store's job: a small collection of atoms (individual pieces of state) that get updated when the relevant fact arrives, and that components can subscribe to directly. gon-stack builds its atoms on top of nanostores in packages/react-kit/src/supa-store.ts, which adds reset(), getInitial(), and a use() hook to every atom.
user-profile-setup/core/store.ts shows a real module store, atoms and derived computed values side by side:
export const createStore = () => {
const $activeStepIndex = atom(0);
const $steps = atom<Step[]>([]);
return {
$activeStepIndex,
$steps,
$hasPreviousStep: computed(
[$activeStepIndex],
(activeStepIndex) => activeStepIndex > 0,
),
$activeStep: computed(
[$activeStepIndex, $steps],
(activeStepIndex, steps) => steps[activeStepIndex],
),
$progressPercentage: computed(
[$activeStepIndex, $steps],
(activeStepIndex, steps) => (activeStepIndex / steps.length) * 100,
),
$stepAnswers: computed(
[$activeStepIndex, $steps],
(activeStepIndex, steps) =>
steps[activeStepIndex].questions.reduce<Answers>((acc, question) => {
acc[question.key] = question.value;
return acc;
}, {}),
),
// ...plus $isIdle, $isLoading, $isStarted, $isFinished, $isSaving, $isSaved, $error
};
};
Crucially, the store does not know or care who triggered the event - it just gets .set(...) calls from handlers. This keeps it decoupled from any particular UI flow.
The Facade: Hiding the Bus Behind a Stable API
Nothing stops you from putting the raw store and a trigger(...) function straight into Context, and letting components call trigger('[TRIGGER]_NEXT', values) directly. But user-profile-setup adds one more seam: a facade that wraps the store and the trigger function in a single object of plain, named methods, so components never see a $ atom or a trigger string at all.
user-profile-setup/core/facade.ts:
export const createFacade = (store: Store, trigger: Registry['trigger']) => {
return {
init: () => trigger('[TRIGGER]_INIT'),
start: () => trigger('[TRIGGER]_START'),
prev: () => trigger('[TRIGGER]_PREV'),
next: (payload: Answers) => trigger('[TRIGGER]_NEXT', payload),
saveAnswers: () => trigger('[TRIGGER]_SAVE_ANSWERS'),
useIsLoading: () => store.$isLoading.use(),
useActiveStep: () => store.$activeStep.use(),
useProgressPercentage: () => store.$progressPercentage.use(),
useHasPreviousStep: () => store.$hasPreviousStep.use(),
// ...and one more pair per atom the UI needs
};
};
A component calls ctx.useActiveStep() and ctx.next(answers) - readable method names instead of raw event strings, with the exact same bus-plus-store machinery running underneath.
The Context: Wiring It All Together
Context's only responsibility here is to hand components a reference to the facade without every component needing to know how the bus, store, or facade were constructed. gon-stack has a generic, reusable factory for this in packages/react-kit/src/context.tsx - it takes a name and a hook, and returns a typed Provider plus a useContext that throws if you forget to wrap your tree.
The module composes store, registry, and facade into a mediator - user-profile-setup/core/mediator.ts and user-profile-setup/presentation/context.tsx:
// core/mediator.ts
export const createMediator = () => {
const store = createStore();
const { trigger, register } = createRegistry(store);
const facade = createFacade(store, trigger);
return { facade, register };
};
// presentation/context.tsx
export const [Provider, useContext] = context(
FEATURE_NAME,
({ mediatorFactory } = { mediatorFactory: createMediator }) => {
const [{ facade, register }] = useState(mediatorFactory);
useLayoutEffect(() => {
const unsub = register();
return () => unsub();
}, [register]);
return facade;
},
);
Components never import the bus, the store, or the facade constructor directly - they call useContext() and get back the facade. The injectable mediatorFactory default is a small bonus: tests can pass in a fake mediator without touching RxJS or nanostores at all (explained later).
Application #1: Decoupling User Actions from Side Effects
Consider the wizard's "Let's go" button. Without this pattern, the component that owns it often ends up owning the loading flags, the reset logic, and the step-advancing logic too - all mixed into one onClick handler.
With triggers and facts, the component's job stays this small. user-profile-setup/presentation/welcome.tsx:
const ctx = useContext();
const canStart = !ctx.useIsLoading() && !ctx.useIsIdle() && !ctx.useHasError();
<Button disabled={!canStart} onClick={ctx.start}>
Let's go
</Button>
Everything else lives in a handler file that has nothing to do with rendering. user-profile-setup/core/handlers/start.ts reacts to the trigger and resets the relevant state:
export const start = (store: Store, { ofType }: Bus) =>
ofType('[TRIGGER]_START').pipe(
tap(() => {
store.$error.reset();
store.$isStarted.set(true);
store.$isFinished.reset();
store.$activeStepIndex.reset();
}),
);
The same shape scales to async work. user-profile-setup/core/handlers/init.ts reacts to [TRIGGER]_INIT by calling the config API and turning the result into store updates, cancelling the in-flight request on cleanup:
export const init = (store: Store, { ofType }: Bus) =>
ofType('[TRIGGER]_INIT').pipe(
tap(() => {
store.$isLoading.set(true);
store.$error.reset();
}),
map(() => new AbortController()),
switchMap((ctrl) =>
from(getConfig(ctrl.signal)).pipe(
tap((steps) => store.$steps.set(steps)),
catchError((error) => {
store.$error.set(error instanceof Error ? error.message : 'Failed to load profile setup configuration.');
return EMPTY;
}),
finalize(() => {
store.$isLoading.reset();
ctrl.abort();
}),
),
),
);
You can unit test either handler with a fake store and zero React involved - it is just "event in, store update(s) out."
Application #2: Keeping Components Reactive Without Prop Drilling
Because components subscribe to individual atoms (through the facade's useXxx() methods) rather than a single monolithic context value, adding a new consumer deep in the tree costs nothing. user-profile-setup/presentation/step.tsx reads only what it needs:
const ctx = useContext();
const activeStep = ctx.useActiveStep();
const hasPreviousStep = ctx.useHasPreviousStep();
const stepAnswers = ctx.useStepAnswers();
This also solves the classic "Context causes every consumer to re-render" complaint: the Context value itself (the facade reference) never changes after mount, so only the individual use() subscriptions - scoped to whichever atom a component actually reads - trigger re-renders.
The whole module then bootstraps itself in one line. user-profile-setup/presentation/main.tsx:
export const Main = () => {
const ctx = useContext();
useEffect(() => {
ctx.init();
}, [ctx]);
return (
<main className="page-bg min-h-screen flex items-center justify-center p-4 md:p-8">
<section className="w-full max-w-2xl variant-card p-6 md:p-8 flex flex-col gap-6">
<Router />
</section>
</main>
);
};
Application #3: Cross-Module Communication
The same bus that powers one module's internal flow can be used to let separate modules talk to each other without importing each other's internals. A module can listen for a [FACT]_* published elsewhere and react to it - say, unlocking an achievement once a profile is finished - without knowing anything about the sending module's store shape. The only contract between two modules is the shape of the events they agree to exchange, defined once in a shared contracts file, not a tangle of direct imports.
Advanced Patterns: Registries and Testability
One refinement makes this pattern scale past a handful of events: a registry. Instead of scattering subscriptions everywhere, a single function collects every handler for a module and wires them up (and tears them down) in one place. user-profile-setup/core/registry.ts:
export const createRegistry = (store: Store) => {
const bus = createBus();
const register = bus.createRegistry(
init(store, bus),
start(store, bus),
prev(store, bus),
next(store, bus),
saveAnswers(store, bus),
editAnswers(store, bus),
);
return { trigger: bus.trigger, register };
};
createRegistry (defined in libs/eda/index.tsx) merges every handler stream, wraps each one in its own error boundary so a bug in one handler doesn't take down the others, and returns a single unsubscribe function - which is exactly what context.tsx calls inside its useLayoutEffect cleanup. One file to check when debugging "why didn't this fact fire," and one place that guarantees nothing leaks when the provider unmounts.
Because every handler is just "event in, store update(s) out," each one - init, start, next, saveAnswers - can be tested in isolation: emit a trigger into a bus, assert on what the store looks like afterward. No rendering, no DOM, no mocking React at all.
A Note on Ordering and Race Conditions
Decoupling triggers from facts buys clarity, but it also reintroduces a problem you may recognize from fetch calls fired in quick succession: nothing guarantees an async handler resolves in the order its trigger was emitted. user-profile-setup/core/handlers/save-answers.ts handles this with RxJS's exhaustMap, which ignores new [TRIGGER]_SAVE_ANSWERS events while one is already in flight, and cancels its own in-flight request with an AbortController on finalize:
export const saveAnswers = (store: Store, { ofType }: Bus) =>
ofType('[TRIGGER]_SAVE_ANSWERS').pipe(
map(() => new AbortController()),
tap(() => {
store.$isSaved.reset();
store.$isSaving.set(true);
}),
exhaustMap((ctrl) =>
from(saveUserProfileAnswers(store.$allAnswers.get(), ctrl.signal)).pipe(
tap(() => store.$isSaved.set(true)),
catchError((error) => {
store.$error.set(error instanceof Error ? error.message : 'Failed to save profile answers.');
return EMPTY;
}),
finalize(() => {
store.$isSaving.reset();
ctrl.abort();
}),
),
),
);
The init handler shown earlier takes the opposite tradeoff: it uses switchMap instead of exhaustMap, so a newer [TRIGGER]_INIT cancels and replaces an older in-flight config load rather than ignoring it. Same tool, different operator, chosen per the semantics of the action - saving answers should not be interrupted by an accidental double-submit, while re-initializing should always win.
This is a small addition, but it is the difference between an event-driven store that is merely elegant and one that stays correct under real, messy user behavior.
Testing: What This Buys You in Practice
Every layer covered so far - bus, store, facade, Context - has one payoff that's easy to claim and harder to show: it makes testing simpler, and it lets you choose which layer a given test exercises instead of always going through the whole tree.
End to End, Through the Real Provider
user-profile-setup/__tests__/main.test.tsx shows the top of that range, testing the real module with nothing mocked except the network boundary:
server.use(
http.get('/api/config/user-profile', () =>
HttpResponse.json({ code: 200, groups: [/* ... */] }),
),
);
render(
<Provider>
<Main />
</Provider>,
);
await user.click(screen.getByRole('button', { name: /let's go/i }));
await screen.findByRole('heading', { name: 'Basics' });
No mock of the store, the bus, the facade, or Context - the test renders the real Provider, clicks the real button, and lets the real [TRIGGER]_INIT → getConfig() → store.$steps.set(...) chain run end to end, with only fetch intercepted by MSW. Compare that to a component that calls fetch inline: mocking it usually means reaching into module internals or stubbing global.fetch, and the test knows nothing about whether the request was actually triggered by the right user action.
Because the module's entire public surface is the facade, the same file can also assert on a full failure-then-retry cycle without ever importing core/store.ts or core/handlers/* directly:
it('shows error when config fetch fails and recovers on retry', async () => {
server.use(
http.get('/api/config/user-profile', () => new HttpResponse(null, { status: 500 })),
);
render(<Provider><Main /></Provider>);
await screen.findByText("We couldn't load your profile setup");
await user.click(screen.getByRole('button', { name: 'Retry' }));
await screen.findByRole('button', { name: /let's go/i });
});
That test exercises init's catchError branch, the $error atom, the error template component, and the [TRIGGER]_INIT retry path - several files' worth of collaboration - through one HTTP mock and one click. That's the concrete return on the extra indirection: a module's tests read like a user's story, not like a list of mocked collaborators.
One Layer at a Time
The other end of the range is testing a single layer with nothing else involved - no Provider, no MSW, sometimes no React at all. Each handler is just a function of (store, bus), so you can exercise it directly against a real store and a real bus, with no rendering:
import { createStore } from '../core/store';
import { createBus } from '../core/bus';
import { start } from '../core/handlers/start';
test('start handler marks the wizard as started and resets the step index', () => {
const store = createStore();
const bus = createBus();
const subscription = start(store, bus).subscribe();
bus.trigger('[TRIGGER]_START');
expect(store.$isStarted.get()).toBe(true);
expect(store.$activeStepIndex.get()).toBe(0);
subscription.unsubscribe();
});
Because RxJS Subjects emit synchronously, bus.trigger(...) runs the handler's tap immediately - no await, no MSW, no component in sight. Calling subscription.unsubscribe() afterward isn't strictly load-bearing here - store and bus are fresh locals that get garbage-collected once the test ends, so nothing leaks across tests - but it's cheap, and it mirrors the same cleanup discipline the registry and the Provider's useLayoutEffect enforce in the real module. This is where you'd put the assertions for next's answer-merging logic or saveAnswers's exhaustMap behavior, without needing a browser-like DOM at all.
One level up from that, you can render a single presentation component - not the whole wizard - by reusing the exact seam shown earlier in "The Context: Wiring It All Together": the Provider's injectable mediatorFactory. Swap in a fake facade, and Welcome renders with no Router, no other steps, and no network involved:
import { render, screen } from '@testing-library/react';
import { Provider } from '../presentation/context';
import { Welcome } from '../presentation/welcome';
test('Welcome disables the start button until a config has loaded', () => {
render(
<Provider
value={{
mediatorFactory: () => ({
facade: {
useIsLoading: () => false,
useIsIdle: () => true,
useHasError: () => false,
useTotalSteps: () => 0,
start: () => {},
},
register: () => () => {},
}),
}}
>
<Welcome />
</Provider>,
);
expect(screen.getByRole('button', { name: /let's go/i })).toBeDisabled();
});
Same Welcome component, same Provider, zero MSW, zero handlers, zero store - just a fake facade shaped like the real one. Whether a given test should exercise one handler, one component, or the whole wizard end to end is a choice you make per test, not a constraint the architecture forces on you.
FAQ
Why RxJS instead of useReducer, Zustand, or Redux Toolkit?
gon-stack's own stack documentation picks it for one reason: RxJS is a strong fit for complex async flows. The handlers above show what that means concretely - exhaustMap to ignore a duplicate submit while one is in flight, switchMap to let a newer request cancel an older one, AbortController wired through finalize for cleanup. Nanostores is kept for exactly what RxJS is overkill for: plain UI state that just needs a lightweight, framework-agnostic .use() hook. Neither tool replaces the other here - the bus owns async orchestration, nanostores owns the resulting values.
Why not just use useQuery (TanStack Query) for the config fetch and the save call?
This one is my own opinion, not something the codebase enforces: I don't reach for useQuery inside a module built this way, even though gon-stack uses TanStack Query elsewhere for plain data-layer fetching. A few concrete problems it introduces here:
- It splits state across two systems. A query's data lives in the Query Client's cache, not in an atom and not behind a
[FACT]. Now "what does the UI currently know" has two answers depending on which system you ask, and debugging means checking both the event log and the query cache instead of one store. - It doesn't fit multi-step, derived flows.
initdoesn't just fetch - it resets seven atoms first, and$activeStep/$progressPercentage/$stepAnswersare all derived from$stepsplus$activeStepIndex. Query keys and refetch semantics are built for "fetch this resource," not for "fetch this, then reset that, then let three unrelated computed values recompute in a particular sequence." - It pulls orchestration back into components. The entire point of the bus-plus-handler split is that
init,next, andsaveAnswerslive in one file each, incore/handlers, testable without React. SpreadinguseQuery/useMutationcalls acrosswelcome.tsx,step.tsx, andfinal.tsxundoes that - each component would own its own slice of fetching logic again, exactly the orchestration-in-components problem this whole pattern exists to avoid. - It adds a second invalidation model to get right. Query keys,
staleTime, and manualinvalidateQueriescalls are one more thing to reason about on top of atoms you already own and can.set()or.reset()directly - for state this simple, that's a cost with no matching benefit.
None of this means TanStack Query is a bad tool - it's a good one for what gon-stack's data layer notes describe it for: straightforward resource fetching with caching. It's a poor fit specifically for state that a bus-and-store pipeline already owns.
Isn't all of this overkill for a small module?
Sometimes, yes. apps/romantic-app/src/libs/eda/README.md says as much: this pattern is a great fit for complex async flows and one action fanning out to multiple side effects, but calls plain CRUD "might be overkill," and single-component state or form validation "use local state" / "use form libraries" instead. user-profile-setup follows that split itself - field-level validation (required, min/max length) is handled by react-hook-form inside step.tsx, not by the bus. The bus only owns what's genuinely cross-cutting: step progression, loading/error/saving flags, and persistence.
Is this "the" architecture to use, or just what this module happens to use?
Just what this module happens to use, for a case complex enough to earn it - none of this is presented as the one correct way to manage React state. Treat it as a ceiling you can scale down from, not a floor everything has to start at. If a module doesn't have real async races or fan-out to justify RxJS, drop it: the bus interface is small (ofType, trigger, createRegistry), so replacing it with plain callbacks or a useReducer is a local, mechanical change - nothing outside the module's core/ folder needs to know. The same goes for the event-driven layer as a whole: if a module is simple enough, it's entirely reasonable to skip triggers and handlers altogether and just have store + Context + UI + integration - components call the integration layer directly, set atoms on success, done. Reach for more structure when the complexity is actually there (multiple async flows, cross-module facts, races to guard against), not by default.
Where did the [FACT] events go? The handlers just tap and .set() directly.
Good catch if you compared the four-stage naming convention against start.ts, next.ts, or init.ts: none of them emit a separate [FACT]_* event before updating the store - they react to [TRIGGER]_* and call store.$x.set(...) right there in the same tap. That's a deliberate shortcut for cases where nothing needs a discrete event in between: no second handler needs to react to "the step advanced" before the store update happens. The full TRIGGER → TASK → FACT chain earns its keep when there's real async work to name, or when a fact needs to fan out to more than one handler - that's the point to reach for forwardAs and a separate fact handler, not before.
What stops someone from just calling fetch inside the component instead?
Nothing at the type-checker level - it's a convention, not a compiler rule. What makes it stick in practice is that the alternative gets visibly worse once a module has more than one or two triggers: the testing section above shows what you'd lose, and the moment a second component needs to react to the same completed fetch, an inline call in one component's onClick has nowhere natural to broadcast that.
Can this grow into time-travel debugging, an undo/redo stack, or middleware like Redux DevTools?
Nothing in user-profile-setup does this today, but the pieces are already in place because ofAny() gives you the entire event stream as data, not just a debug log. Recording every [FACT]_* in order - each one already timestamped and uniquely identified via meta - is exactly what event sourcing needs to reconstruct or rewind state: replay the recorded facts back through the same store and you get the state at any point in time, which is the basis of an undo stack or a "back in time" debugger. The same ofAny() subscription is also the natural place to bolt on cross-cutting middleware - analytics, structured logging, error reporting - without touching a single handler, since it sees every event flowing through the bus regardless of which handler reacts to it.
Should a handler ever call trigger(...) itself?
No - the eda README calls this out specifically because it breaks traceability: a handler that calls trigger(...) hides a second entry point inside what looks like a leaf node. If something needs to cause a further reaction, emit a fact or effect instead, so the whole chain stays visible from one registry file.
Do I need the facade, or can components use the store and trigger() directly?
Either works - createFacade is one extra file, not a requirement of the pattern. It earns its cost when a module's components are written by people who shouldn't need to know an event bus exists, or when you want the freedom to rename an atom or restructure a handler without touching every component that reads it. For a two-component module, skip it.
Summary
Event-driven architecture, a store, a facade, and React Context are not competing state management solutions - they are layers that each do one job well:
- The bus captures intent and turns it into a traceable sequence of triggers, tasks, facts, and effects.
- The store turns facts into durable, subscribable state, with no knowledge of who caused them.
- The facade turns raw atoms and trigger strings into a small, readable, named API.
- Context delivers that facade to components, without prop drilling and without every consumer re-rendering on every change.
The result is components that stay small and declarative, business logic that lives in testable handler functions instead of onClick callbacks, and modules that can grow independently because their only shared contract is a set of well-named events. If your app's state is starting to feel tangled between UI, side effects, and cross-component communication, this is a pattern worth reaching for before you reach for a heavier state management library.
Currently engaged in mentoring, sharing insights through posts, and working on a variety of full-stack development projects. Focused on helping others grow while continuing to build and ship practical solutions across the tech stack. Visit my Linkedin or my site for more 🌋🤝
Bullshit Meter
Looks solid
Bullshit score
1.1/10
Comments (0)
No comments yet
Be the first to comment on this document.