tap is a synchronous observer that fires for every action handler invocation inside the boundary. It is the single seam for cross-cutting observability: analytics, audit logging, devtools panels, Sentry breadcrumbs, replay traces for bug reports.
The tap is intentionally low-level. It does not classify events as "interesting", does not buffer them, does not retry. Consumers compose those concerns on top — the library only guarantees the firing contract.
Pass tap to App() next to env — this is where bootstrap-time configuration lives and where Sentry / analytics SDK handles are already set up.
// app.ts
import { App, type Taps } from "march-hare";
function tap(event: Taps) {
if (event.stage === "start") {
console.debug(`[${event.action.name}] started`, event.action.payload);
return;
}
if (event.result === "success") {
console.debug(
`[${event.action.name}] succeeded in ${event.details.elapsed}ms`,
);
return;
}
console.error(
`[${event.action.name}] errored after ${event.details.elapsed}ms`,
event.details.error,
);
}
export const app = App({
env: { session: null, locale: "en-GB" },
tap,
});<app.Boundary> then renders with no extra props at the root:
<app.Boundary>
<Root />
</app.Boundary>Both env and tap are fixed at App() time; <app.Boundary> deliberately does not accept overrides. If a test render or storybook needs a different initial Env or a spy tap, declare a separate App with that configuration and render its boundary instead.
For low-level scenarios where you have not adopted App(), the bare <Boundary tap={...}> accepts the same callback — App() is the typed wrapper around it.
The boundary re-renders without invalidating the tap context when the callback identity changes — the provider stores the callback in a ref, so inline arrow functions are safe.
Events are emitted per handler invocation, not per dispatch. A broadcast that reaches five subscribers produces five independent event streams — each stream is exactly two events, with a mutually-exclusive terminal.
The shape is a two-level discrimination:
stage—"start"(handler began) or"end"(handler completed).result— onendonly,"success"or"error".
Per handler:
- Succeeded:
stage: "start"→stage: "end", result: "success". - Failed:
stage: "start"→stage: "end", result: "error".
Every event carries action: { name, payload } at the top level. Everything else — task, elapsed, mutations, and (on errors) error / reason — lives on details. Consumers branch on stage === "end" to access timing and mutation info; on result === "error" to access failure info.
elapsed is measured against performance.now() in milliseconds, computed the moment the end event fires.
These two channels look similar but exist for different reasons.
Lifecycle.Faultis an in-band recovery channel: handlers subscribe to it to react (sign out on auth failure, abort dependent tasks, surface a toast). It only fires on errors.tapis an out-of-band observability channel: it sees every handler start and every end (success or error). It is not intended to mutate model state or dispatch follow-up actions.
Subscribe to both. Faults handle the consequences of failure; the tap records that failure for the people running the application.
Feed action timings into an analytics or APM pipeline:
import { Boundary, type Taps } from "march-hare";
import { metrics } from "./instrumentation";
function tap(event: Taps) {
if (event.stage !== "end") return;
metrics.histogram("action.duration_ms", event.details.elapsed, {
action: event.action.name,
outcome: event.result,
});
}
<Boundary tap={tap}>
<App />
</Boundary>;Branching on stage === "end" narrows the event to either result variant, exposing elapsed and result; start events carry no elapsed.
For a flight-recorder style trace that surfaces in bug reports, keep a ring buffer of recent events:
import { Boundary, type Taps } from "march-hare";
const trace: Taps[] = [];
const limit = 200;
function tap(event: Taps) {
// eslint-disable-next-line fp/no-mutating-methods
trace.push(event);
if (trace.length > limit) {
// eslint-disable-next-line fp/no-mutating-methods
trace.shift();
}
}
// Attach to bug-report submissions or expose via window for support tooling.
window.marchHareTrace = () => trace;
<Boundary tap={tap}>
<App />
</Boundary>;A 200-event window is roughly the last few seconds of dispatch activity for a busy application — tune to taste. Keep the structure simple: the value of this buffer is being able to ask "what happened just before this exception?", not running queries against it.
import * as Sentry from "@sentry/react";
import { Boundary, type Taps } from "march-hare";
function tap(event: Taps) {
if (event.stage === "start") {
Sentry.addBreadcrumb({
category: "march-hare",
message: event.action.name,
level: "info",
data: { payload: event.action.payload },
});
return;
}
if (event.result === "error") {
Sentry.captureException(event.details.error, {
tags: {
action: event.action.name,
reason: String(event.details.reason),
},
extra: {
elapsed: event.details.elapsed,
payload: event.action.payload,
},
});
}
}
<Boundary tap={tap}>
<App />
</Boundary>;Subscribing to start events (for breadcrumbs) and result === "error" (for captures) keeps the Sentry stream readable — one entry per action started, plus an explicit capture for failures.
- Cached broadcast replays on mount that hit
useActionhandlers do fire the tap — the replay path goes through the samecreateHandlerwrapper. - Direct
peek()andfinal()reads do not fire the tap — these are read-only operations against the broadcast cache, not handler invocations. context.actions.resource(...)calls fire the tap once for the outer handler. The fetcher itself runs inside that handler's lifetime; it does not produce its own tap events.- Generator handlers fire a single
startevent and a single matchingendevent when the generator finishes. The tap does not see individual yields.
The tap is synchronous — the handler invocation is blocked until the callback returns. If the receiver is slow (writing to disk, calling a network API), push to an in-memory queue and drain it elsewhere:
const queue: Taps[] = [];
function tap(event: Taps) {
// eslint-disable-next-line fp/no-mutating-methods
queue.push(event);
scheduleFlush();
}
function scheduleFlush() {
// requestIdleCallback / setTimeout / SharedWorker — drain queue without
// blocking the dispatch path.
}If no tap prop is provided, the internal observer is a no-op — the dispatch path pays only the cost of a single function call per handler invocation.