Backpressure
A high-frequency stream that triggers a state update per event will melt your UI thread. Quote feeds, stream tokens, telemetry, multiplayer cursors — anything that arrives faster than React can paint — needs batching at the receive boundary.
useSocketEventBatch buffers events that match
a discriminator value and flushes the batch to your handler on a fixed
interval. One flush, one render.
Usage
import { useSocketEventBatch } from "@luciodale/react-socket";
function PriceTicker() {
const [latest, setLatest] = useState<Record<string, number>>({});
useSocketEventBatch(
manager,
"tick",
(msgs) => {
// One setState per flush, no matter how many ticks arrived.
setLatest((prev) => {
const next = { ...prev };
for (const m of msgs) next[m.symbol] = m.price;
return next;
});
},
{ flushMs: 100 },
);
return <PriceGrid prices={latest} />;
}Why this matters
Without batching, every event causes a re-render. With it, you cap
renders at 1000 / flushMs:
// Without batching: one re-render per tick. With 200 ticks per
// second, that is 200 renders per second. The component dies.
useSocketEvent(manager, "tick", (msg) => {
setLatest((prev) => ({ ...prev, [msg.symbol]: msg.price }));
});
// With batching: one re-render per flush window. With flushMs: 100,
// that is 10 renders per second regardless of tick rate.
useSocketEventBatch(
manager,
"tick",
(msgs) => setLatest((prev) => mergeTicks(prev, msgs)),
{ flushMs: 100 },
);Picking flushMs
- 16ms (~60 renders / sec): feels live. Use for cursors, scroll, or anything that should look continuous.
- 50–100ms (10–20 / sec): feels responsive but cheaper. Good for price tickers, dashboards.
- 250ms+ (≤ 4 / sec): clearly batched. Use for activity feeds where order matters but freshness does not.
Order is preserved
Events inside a batch arrive in the order the server sent them. Use this for stream-delta or any other accumulating sequence:
useSocketEventBatch(
manager,
"stream-delta",
(msgs) => {
// Order is preserved within a batch, exactly as received.
for (const m of msgs) appendDelta(m.id, m.delta);
},
{ flushMs: 16 },
);Trailing-latency: idleMs
flushMs alone produces a visible stall at
the end of a stream: the last few events sit in the buffer waiting
for the next interval tick. Pass idleMs
alongside it and the batch also flushes after that many ms of
silence on the channel. Typical pairing for an LLM token stream:
{ flushMs: 16, idleMs: 8 } so the
final tokens render without a perceived hang.
// Pair flushMs with idleMs to flush early when the stream goes quiet.
// Without idleMs, the last 1-3 tokens of an LLM stream sit in the buffer
// waiting for the next interval tick, producing a visible stall.
useSocketEventBatch(
manager,
"stream-delta",
(msgs) => {
for (const m of msgs) appendDelta(m.id, m.delta);
},
{ flushMs: 16, idleMs: 8 },
);What it does NOT do
- It does not coalesce duplicates. If the server sends three updates for the same key in one window, your handler sees three entries. Dedupe in the handler.
- It does not flush on unmount. Pending events in the buffer at unmount time are dropped. Persist anything you cannot afford to lose before unmounting.
- It does not signal upstream. Truly congested servers need server-side flow control; client-side batching only protects the renderer.
When to skip it
- Your stream is slow. If events arrive once a second, the per-event hook is fine and simpler.
- Each event must update independent UI atomically. Batching collapses N events into one render — not the model you want for confirm-in-place flows.
- You are using an external store (zustand, redux) that already coalesces. The store deals with churn; the React tree only renders on selector changes.
Next steps
- API reference —
useSocketEventBatchsignature - Binary frames — pair batching with binary for high-throughput streams
- Testing — asserting on batch flushes with fake timers