Publishers and listeners
Fox Events is an event system built around channels: whoever publishes (publisher) emits a payload; whoever listens (listener) receives that payload. This page explains the mental model and how to use publishers and listeners in practice.
---
Table of contents
- 1. The model: channel, publisher, listener
- 2. Flow in one sentence
- 3. Publisher
- 4. Listener
- 5. One publisher, many listeners
- 6. on vs once
- 7. once with timeout
- 8. Unsubscribing (off)
- 9. Who can be a publisher or listener
- 10. What Fox does not do
- 11. Quick examples
- 12. Best practices
---
1. The model: channel, publisher, listener
- Channel — A “topic” with a name (e.g.
"user:login") and a payload type (e.g.{ userId: string }). The channel is the meeting point: only code that knows the channel name can publish or listen on it. You get a channel withFox.channel<T>(name).
- Publisher — Whoever publishes an event on the channel by calling
emit(payload)oremitAsync(payload). The publisher does not know how many listeners there are or who they are; it only sends a value (the payload) to the channel.
- Listener — Whoever subscribes to the channel with
on(callback)oronce(). Every time someone publishes on that channel, the listener receives the payload (in theoncallback or via theoncePromise). There can be zero, one, or many listeners on the same channel.
Fox Events does not store “who published”: it only ensures that every subscribed listener on the channel receives the payload. The payload is a typed value (object, string, etc.); the channel defines the type.
---
2. Flow in one sentence
The publisher calls channel.emit(payload) → Fox delivers the same payload to all listeners on that channel (those on on receive it in their callback; those on once receive it in their Promise and are then unsubscribed).
In code:
const channel = Fox.channel<{ userId: string }>("user:login");
channel.on((payload) => console.log("Listener A:", payload.userId));
channel.on((payload) => console.log("Listener B:", payload.userId));
channel.emit({ userId: "u-1" });
// Listener A: u-1
// Listener B: u-1One emit, two listeners: both get the same event.
---
3. Publisher
A publisher is any code that calls emit or emitAsync on a channel.
emit(payload)— Fires immediately and synchronously. Listeners are invoked right away; the publisher does not wait. Returnsvoid. Use for UI events, navigation, in-memory notifications.
emitAsync(payload)— Returns aPromisethat resolves after all listeners (and any middleware, e.g. persistence) have run. Use when the event goes through middleware (e.g. IndexedDB, bridge to React Native) and you need to wait for that processing before continuing (e.g. “save then redirect”).
The publisher does not need to know who is listening; it can be a button, a service, an external library, or another module in the app. What matters is using the same channel (same name) as the listeners.
---
4. Listener
A listener is any code that subscribes to the channel with on or once.
on(callback)— Subscribes a callback that will be called every time someone publishes on the channel. Returns anoff()function to unsubscribe. Use when you want to react to every event (e.g. update UI on each click, sync state).
once()— Subscribes for only the next event. Returns aPromisethat resolves with that event’s payload; after the first event, the listener is removed automatically. Use when you want “wait for the first occurrence” (e.g. first server response, first “Confirm” click).
Multiple listeners can be on the same channel; they all receive the same payload on each emit. The order in which listeners are called is the order in which they subscribed.
---
5. One publisher, many listeners
It’s common to have one publisher (e.g. a button or a service) and many listeners (e.g. update header, sidebar, log, and analytics).
const loginChannel = Fox.channel<{ userId: string }>("user:login");
loginChannel.on((p) => updateHeader(p.userId));
loginChannel.on((p) => updateSidebar(p.userId));
loginChannel.on((p) => analytics.track("login", p));
document.getElementById("login-btn")?.addEventListener("click", () => {
loginChannel.emit({ userId: "u-42" });
});A single emit triggers all three listeners. The publisher is not coupled to any of them; each listener handles its own concern.
---
6. on vs once
| on | once | |
|---|---|---|
| How many events | All (while subscribed) | Only the next one |
| Return value | off() function to unsubscribe | Promise<payload> |
| Unsubscribe | Manual (off()) | Automatic after first event |
| Typical use | Reactive UI, logs, sync | “Wait for one response”, single confirmation |
Example with on: subscribe once and react to every click.
const off = channel.on((payload) => setCount((c) => c + 1));
// later: off() to stop receivingExample with once: wait for the next event (e.g. user confirmation).
const payload = await channel.once();
console.log("User confirmed:", payload);---
7. once with timeout
If no event happens within a time limit, once() can reject the Promise instead of waiting forever. Use the timeout option (in milliseconds):
try {
const payload = await channel.once({ timeout: 5000 });
console.log("Received within 5s:", payload);
} catch (err) {
console.error("No event within 5s", err);
// err.message: "once: timeout after 5000ms"
}Useful for “wait for server response within X seconds” or “wait for confirmation click within X seconds”; if the time runs out, you handle the error (e.g. show message, revert state).
---
8. Unsubscribing (off)
- on:
on(callback)returns a function. Call that function to stop receiving events (e.g. in ReactuseEffectcleanup).
const off = channel.on((payload) => { /* ... */ });
// when you no longer need it:
off();- once: no need to unsubscribe; after the first event the subscription is removed automatically. If you use
once({ timeout })and it times out, the subscription is also removed.
In React components, subscribe in useEffect and call off() in the return to avoid memory leaks and updates after unmount.
---
9. Who can be a publisher or listener
- Publisher: click handler, API service, worker, another library, global store, React Native bridge, etc. Any code that has access to the channel and calls
emitoremitAsync.
- Listener: React component (in
useEffect), analytics module, logger, derived store, modal that “waits for a response”, etc. Any code that callsonoronceon the channel.
Publisher and listener can live in different files, layers, or bundles; the only link is the channel name and payload type. That keeps things decoupled and easy to test (emit from a test and assert the listener was called).
---
10. What Fox does not do
- Does not store “who published” — The payload is delivered as-is; there is no “origin” metadata. If you need it, add a field to the payload (e.g.
{ userId, emittedBy: "header" }).
- Does not guarantee order across channels — Within one channel, listeners are called in subscription order. Across different channels, there is no defined order.
- Does not persist events by default — Each emit is delivered to current listeners only. For persistence (e.g. IndexedDB), use the optional package and middleware; see Storage.
- Does not retry on listener error — If a listener throws, Fox does not redeliver the event. Handle errors inside the listener or use a dead-letter pattern if needed.
---
11. Quick examples
One listener, one publisher (button):
const ch = Fox.channel<{ value: string }>("app:input");
ch.on((p) => console.log(p.value));
document.querySelector("button")?.addEventListener("click", () => {
ch.emit({ value: "clicked" });
});Many listeners, one publisher (service):
const ch = Fox.channel<{ id: string }>("api:user-loaded");
ch.on((p) => setUser(p));
ch.on((p) => analytics.identify(p.id));
getUser().then((user) => ch.emit(user));Listener that waits only for the next event (once):
const ch = Fox.channel<{ confirmed: boolean }>("modal:confirm");
const result = await ch.once();
if (result.confirmed) { /* ... */ }once with timeout:
const ch = Fox.channel<{ token: string }>("auth:token");
try {
const { token } = await ch.once({ timeout: 10000 });
saveToken(token);
} catch {
showError("Login expired. Try again.");
}---
12. Best practices
- Name channels clearly — Use conventions like
domain:action(e.g.user:login,cart:item-added) so they’re easy to find and don’t collide.
- Type the payload — Use
Fox.channel<YourType>(name)soemitandon/oncecallbacks are typed; fewer bugs and better autocomplete.
- Unsubscribe when you’re done — In React, call
off()in theuseEffectreturn; elsewhere, call it when the listener no longer makes sense (e.g. modal closed).
- Prefer once when “once is enough” — Confirmations, “wait for first response”, etc. Avoids forgetting
off()and makes intent clear.
- Use timeout with once for time-bounded flows — Avoids hanging Promises when the event might never happen (network, user left the screen).
For the full API (trail, middleware, scopes), see Getting started and the other documentation modules.