Fox Events Fox Events

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

  • 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 with Fox.channel<T>(name).
  • Publisher — Whoever publishes an event on the channel by calling emit(payload) or emitAsync(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) or once(). Every time someone publishes on that channel, the listener receives the payload (in the on callback or via the once Promise). 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:

ts
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-1

One 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. Returns void. Use for UI events, navigation, in-memory notifications.
  • emitAsync(payload) — Returns a Promise that 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 an off() 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 a Promise that 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).

ts
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

ononce
How many eventsAll (while subscribed)Only the next one
Return valueoff() function to unsubscribePromise<payload>
UnsubscribeManual (off())Automatic after first event
Typical useReactive UI, logs, sync“Wait for one response”, single confirmation

Example with on: subscribe once and react to every click.

ts
const off = channel.on((payload) => setCount((c) => c + 1));
// later: off() to stop receiving

Example with once: wait for the next event (e.g. user confirmation).

ts
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):

ts
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 React useEffect cleanup).
ts
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 emit or emitAsync.
  • Listener: React component (in useEffect), analytics module, logger, derived store, modal that “waits for a response”, etc. Any code that calls on or once on 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):

ts
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):

ts
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):

ts
const ch = Fox.channel<{ confirmed: boolean }>("modal:confirm");
const result = await ch.once();
if (result.confirmed) { /* ... */ }

once with timeout:

ts
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) so emit and on / once callbacks are typed; fewer bugs and better autocomplete.
  • Unsubscribe when you’re done — In React, call off() in the useEffect return; 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.