Patterns
End-to-end patterns for @pond-ts/react. Assumes you've read
Concepts and skimmed Hooks.
One source, many charts
The typical dashboard: one live source owned at the top of the tree, read by multiple children with per-chart views.
import { useLiveSeries, useLiveQuery, useCurrent } from '@pond-ts/react';
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
{ name: 'host', kind: 'string' },
] as const;
function Dashboard() {
const [live, snap] = useLiveSeries({
name: 'metrics',
schema,
retention: { maxAge: '1h' },
});
// Push from your data source (WebSocket, polling, etc.)
useEffect(() => {
const ws = new WebSocket('/api/metrics');
ws.onmessage = (e) => {
const { ts, cpu, host } = JSON.parse(e.data);
live.push([ts, cpu, host]);
};
return () => ws.close();
}, [live]);
if (snap === null) return <Skeleton />;
return (
<>
<StatBar live={live} />
<ChartPerHost live={live} host="api-1" />
<ChartPerHost live={live} host="api-2" />
<RollingBand live={live} />
</>
);
}
Each child subscribes to the same live source — no prop-drilling
snapshots, no parent re-render when a child throttles differently.
Per-host charts
function ChartPerHost({
live,
host,
}: {
live: LiveSeries<Schema>;
host: string;
}) {
const [, snap] = useLiveQuery(
() => live.filter((e) => e.get('host') === host),
[live, host],
);
return snap && <Chart points={snap.select('cpu').toPoints()} label={host} />;
}
The view is memoized against [live, host], so changing host
rebuilds the filter view but a sibling changing its own props doesn't
rebuild ours.
Stat bar
function StatBar({ live }: { live: LiveSeries<Schema> }) {
const { cpu, host } = useCurrent(live, {
cpu: 'avg',
host: 'unique',
});
const recent = useCurrent(live, { cpu: 'p95' }, { tail: '30s' });
return (
<header>
<Stat label="Avg CPU" value={cpu} />
<Stat label="p95 CPU (30s)" value={recent.cpu} />
<Stat label="Hosts" value={host?.join(', ') ?? '—'} />
</header>
);
}
Three reducer reads against the same source; each is independently
throttled; each stat renders only when its own values change (thanks
to per-field reference stability on useCurrent).
Rolling band chart
Snapshot the live source, then run baseline on the snapshot — you get
avg, sd, upper, lower columns on one pass, with the
sd === 0 flat-window handling
for free.
function RollingBand({ live }: { live: LiveSeries<Schema> }) {
const snap = useSnapshot(live);
const baseline = useDerived(snap, (s) =>
s.baseline('cpu', { window: '5m', sigma: 2 }),
);
if (!baseline) return null;
// toPoints() returns wide rows with avg / sd / upper / lower as
// top-level keys; pass the array straight to a chart that reads
// those keys.
return <BandChart data={baseline.toPoints()} />;
}
useDerived memoizes the baseline computation against the snapshot
identity — it only recomputes when the snapshot actually changes
(which is throttled at the useSnapshot layer).
Throttling strategy
Default throttle is 100 ms. When to change:
| Scenario | Throttle |
|---|---|
| Status readout, stat card | 200–500ms |
| Standard line chart | 100ms (default) |
| Animation-heavy (tooltip, cursor-track) | 16ms (~60fps) |
| Background refresh / off-screen chart | 1000ms |
| Tests / SSR / deterministic rendering | 0 (immediate) |
Within one component, different hooks can have different throttles: the stat bar at 500 ms, the main chart at 100 ms, the hover overlay at 16 ms. They all subscribe to the same source; React batches the renders.
Retention + hooks
Retention is a property of the source, not the hook. Set it once at construction and every hook downstream inherits:
const [live, snap] = useLiveSeries({
name: 'metrics',
schema,
retention: { maxAge: '10m' },
});
Hooks that wrap the source (useWindow, useLiveQuery) operate on
the retained view — a useWindow(live, '5m') over a 10m-retention
source gives you the intersection (never more than 10 m of source
events visible; never more than 5 m in the windowed view).
The 'evict' event fires when retention runs — subscribe outside the
hook if you need to react:
useEffect(() => {
const unsub = live.on('evict', (events) => {
metrics.increment('events.evicted', events.length);
});
return unsub;
}, [live]);
Cleanup
- Source hooks — no explicit cleanup. The live object is GC'd when the component unmounts.
- Snapshot hooks — unsubscribe from the source on unmount, automatically.
useWindow— disposes the underlyingLiveView.window()on unmount or whensizechanges.useLiveQuery— rebuilds the view when deps change; the old view'sdispose()is called if available.
If you construct pond-ts live objects outside a hook (e.g. in a
useEffect), call .dispose() manually in the cleanup:
useEffect(() => {
const agg = new LiveAggregation(live, Sequence.every('1m'), { cpu: 'avg' });
const unsub = agg.on('close', (e) => log(e));
return () => {
unsub();
agg.dispose();
};
}, [live]);
Testing
The @pond-ts/react test suite uses @testing-library/react +
happy-dom. Same setup works for consumers.
import { act, render } from '@testing-library/react';
import { useLiveSeries } from '@pond-ts/react';
it('renders on push', () => {
let liveRef: LiveSeries<Schema> | null = null;
function Fixture() {
const [live, snap] = useLiveSeries(
{
name: 't',
schema,
// throttle 0 so renders are synchronous in tests:
},
{ throttle: 0 },
);
liveRef = live;
return <span data-testid="len">{snap?.length ?? 0}</span>;
}
const { getByTestId } = render(<Fixture />);
expect(getByTestId('len').textContent).toBe('0');
act(() => {
liveRef!.push([1000, 0.5, 'a']);
});
expect(getByTestId('len').textContent).toBe('1');
});
Setting throttle: 0 is the key — the default 100 ms makes tests
flake if they don't wait for the throttle window.
SSR / Next.js / Remix
The hooks are client-only today: useLiveSeries returns an empty
series on the first server render, and subsequent mounts create the
live object. If you need SSR'd initial data, pass it through
useTimeSeries instead (pure batch, no subscriptions), then hydrate
into a useLiveSeries on mount:
function ServerHydratedChart({ initial }: { initial: JsonPayload }) {
const initialSeries = useTimeSeries(initial);
const [live] = useLiveSeries({ name: initial.name, schema });
useEffect(() => {
for (const event of initialSeries.events()) {
live.push(event);
}
}, [live, initialSeries]);
const snap = useSnapshot(live);
return snap && <Chart points={snap.select('cpu').toPoints()} />;
}
No server-side LiveSeries needed; the server just emits JSON.
Production considerations (not yet built in)
Three things the library doesn't bake in yet; if you hit one, roll it yourself and tell us what you needed:
- Error boundaries.
live.push(...)throws on schema violations (see LiveSeries → ordering modes). Hooks don't catch those — wrap your dashboard in a React error boundary if you want a fallback UI rather than a crashed tree. - Context-shared sources. The patterns above pass
livevia props. For deeper trees, hoist into aReact.Contextyourself —useLiveSeriesat a provider, consumers call their own hooks against the sharedliveinstance. There's no first-party provider yet. - Next.js / Remix hydration mismatches.
useLiveSeriesreturns an empty series on the server and a fresh live object on the client. If your SSR'd markup reflects a specific event count and the client mounts before any pushes arrive, the initial hydration matches — but any client-side push before hydration completes will produce a warning. The SSR pattern above avoids this by feeding server-provided data throughuseTimeSeriesinstead.
All three are candidates for future library-side fixes; none is fundamentally blocking today.
Where to go next
- Concepts — the mental model these patterns assume.
- Hooks — per-hook reference.
- Recipes — full worked dashboards (CPU metrics, error rates, streaming).
- API reference (react) — every signature and type.