row-store
Applies feed batches and lets components subscribe to individual rows.
import { memo, useState } from "react"
import { useRow, useRowIds } from "@/hooks/use-row-store"
import { createRowStore, type RowStore } from "@/lib/row-store"
interface Quote { id: string; price: number }
const QuoteRow = memo(function QuoteRow({ store, id }: { store: RowStore<Quote>; id: string }) {
const quote = useRow(store, id)
if (!quote) return null
return (
<tr className="border-t">
<th scope="row" className="px-3 py-1 text-left font-medium">{id}</th>
<td className="px-3 py-1 text-right">{quote.price.toFixed(2)}</td>
</tr>
)
})
export default function RowStoreDemo() {
const [store] = useState(() => {
const store = createRowStore<Quote>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [{ id: "ALPHA", price: 99.5 }, { id: "BETA", price: 100.25 }] })
return store
})
const ids = useRowIds(store)
const update = () => store.applyDeltas({ patch: [{ id: "ALPHA", fields: { price: store.getRow("ALPHA")!.price + 0.01 } }] })
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1" onClick={update}>Update ALPHA</button>
</div>
<div className="w-fit max-w-full overflow-auto rounded border text-xs lining-nums tabular-nums">
<table aria-label="Quotes">
<thead className="text-muted-foreground"><tr><th scope="col" className="px-3 py-1 text-left font-medium">Symbol</th><th scope="col" className="px-3 py-1 text-right font-medium">Price</th></tr></thead>
<tbody>{ids.map((id) => <QuoteRow key={id} store={store} id={id} />)}</tbody>
</table>
</div>
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/row-store#v1.4.13
npx shadcn@latest add tradecn/ui/row-store#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/row-store#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/row-store#v1.4.13
-
Copy the files into your project:
lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments once per applied batch. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] let idsSnapshot: readonly RowId[] = ids let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, createView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.delete(view)) views.add(view) return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = ids meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements RowView<T> { private ids: readonly RowId[] = [] private readonly listeners = new Set<Listener>() private holdUntil = 0 private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly onDispose: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, onDispose: () => void) { this.store = store this.opts = opts this.onDispose = onDispose this.now = opts.now ?? Date.now this.ids = this.compute() } getIds() { return this.ids } subscribe(cb: Listener) { this.listeners.add(cb) return () => { this.listeners.delete(cb) } } isHeld() { return this.now() < this.holdUntil } touch() { const hold = this.opts.reorderHoldMs ?? 0 if (hold <= 0) return this.holdUntil = this.now() + hold if (this.timer) clearTimeout(this.timer) // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.set(this.compute()) }, hold) } dispose() { if (this.disposed) return this.disposed = true if (this.timer) clearTimeout(this.timer) this.listeners.clear() this.onDispose() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean) { if (!touched.size && !orderChanged) return if (this.isHeld()) { // Frozen order: keep what is there, drop removals and rows that no longer pass the filter, append newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) const next = kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] this.set(next) return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) this.set(this.compute()) } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private set(next: readonly RowId[]) { if (sameOrder(this.ids, next)) return this.ids = next for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { if (handle !== null) { caf(handle) handle = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), gap: Boolean(meta?.gap) || Boolean(delta.meta.gap), } } if (handle === null) { handle = raf(() => { handle = null flush() }) } }, flush, cancel() { if (handle !== null) caf(handle) handle = null reset() }, pending: () => handle !== null, } }hooks/use-row-store.ts import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react" import type { RowId, RowStore, RowView, StoreMeta, ViewOptions } from "@/lib/row-store" /** One row. The component re-renders when this row is replaced or patched, and for nothing else. */ export function useRow<T>(store: RowStore<T>, id: RowId): T | undefined { const subscribe = useCallback((cb: () => void) => store.subscribeRow(id, cb), [store, id]) const get = useCallback(() => store.getRow(id), [store, id]) return useSyncExternalStore(subscribe, get, get) } /** The ids of a store (insertion or authoritative order) or of a view (filtered, sorted, held). */ export function useRowIds<T>(source: RowStore<T> | RowView<T>): readonly RowId[] { const subscribe = useCallback((cb: () => void) => ("subscribeOrder" in source ? source.subscribeOrder(cb) : source.subscribe(cb)), [source]) const get = useCallback(() => source.getIds(), [source]) return useSyncExternalStore(subscribe, get, get) } /** Version, size, lane, drop count, sequence, gap, and timestamps. Changes once per batch. */ export function useStoreMeta<T>(store: RowStore<T>): StoreMeta { const subscribe = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const get = useCallback(() => store.getMeta(), [store]) return useSyncExternalStore(subscribe, get, get) } /** * A view this component owns: made for the options given, disposed on the way out, and made again for * new options. Keep the options object's identity stable (a `useMemo`), since a new object is a new view. * Null options give null, for a component that takes a view from its props instead. * * Why a hook and not a memo with a dispose effect: React's StrictMode mounts, unmounts, and mounts a * component again in development, and the cleanup of that rehearsal disposes the memo's view while the * memo keeps handing it out, so the grid stopped following its store until something remade the view. * The effect here notices a disposed view and makes another. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [view, setView] = useState<RowView<T> | null>(() => (options ? store.createView(options) : null)) const made = useRef({ store, options, view }) useEffect(() => { let current = made.current.view if (made.current.store !== store || made.current.options !== options || (current !== null && current.isDisposed())) { current = options ? store.createView(options) : null made.current = { store, options, view: current } // A view the rehearsal disposed, or new options: the replacement is state so the render that reads it re-runs. setView(current) } return () => current?.dispose() }, [store, options]) return view }
Usage
import { memo, useState } from "react"
import { useRow, useRowIds } from "@/hooks/use-row-store"
import { createRowStore, type RowStore } from "@/lib/row-store"
interface Quote { id: string; price: number }
const QuoteRow = memo(function QuoteRow({ store, id }: { store: RowStore<Quote>; id: string }) {
const quote = useRow(store, id)
if (!quote) return null
return (
<tr className="border-t">
<th scope="row" className="px-3 py-1 text-left font-medium">{id}</th>
<td className="px-3 py-1 text-right">{quote.price.toFixed(2)}</td>
</tr>
)
})
function Quotes() {
const [store] = useState(() => {
const store = createRowStore<Quote>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [{ id: "ALPHA", price: 99.5 }, { id: "BETA", price: 100.25 }] })
return store
})
const ids = useRowIds(store)
const update = () => store.applyDeltas({ patch: [{ id: "ALPHA", fields: { price: store.getRow("ALPHA")!.price + 0.01 } }] })
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border px-2 py-1" onClick={update}>Update ALPHA</button>
</div>
<div className="w-fit max-w-full overflow-auto rounded border text-xs lining-nums tabular-nums">
<table aria-label="Quotes">
<thead className="text-muted-foreground"><tr><th scope="col" className="px-3 py-1 text-left font-medium">Symbol</th><th scope="col" className="px-3 py-1 text-right font-medium">Price</th></tr></thead>
<tbody>{ids.map((id) => <QuoteRow key={id} store={store} id={id} />)}</tbody>
</table>
</div>
</>
)
}
Update ALPHA applies one patch; BETA's snapshot stays unchanged. useRowIds subscribes to the id list, and each useRow subscribes to its own quote. memo lets an unchanged row skip a parent render when the list changes. The button stands in for an already-batched feed callback.
Applying a batch
This example uses the coalesced lane, where a producer can discard superseded ticks. The Apply feed batch button inserts GAMMA, patches ALPHA, removes BETA, and reports three dropped ticks in one call. The row count stays at two. The Report two more drops button applies only metadata: the cumulative drop count reaches five, and the rows stay unchanged.
The seed is batch 1, the mixed update is batch 2, and the metadata-only update is batch 3. Reset creates a fresh store so the rows and metadata can be inspected again from the start. The producer supplies the drop counts; the store does not detect drops. Feeds that must preserve every event use the ordered lane and report sequence and gap metadata instead; see feed-health.
import { memo, useState } from "react"
import { useRow, useRowIds, useStoreMeta } from "@/hooks/use-row-store"
import { createRowStore, type RowStore } from "@/lib/row-store"
interface Quote { id: string; price: number }
function seeded() {
const store = createRowStore<Quote>({ getRowId: (quote) => quote.id, lane: "coalesced" })
store.applyDeltas({ upsert: [{ id: "ALPHA", price: 99.5 }, { id: "BETA", price: 100.25 }] })
return store
}
const QuoteRow = memo(function QuoteRow({ store, id }: { store: RowStore<Quote>; id: string }) {
const quote = useRow(store, id)
if (!quote) return null
return (
<tr className="border-t">
<th scope="row" className="px-3 py-1 text-left font-medium">{id}</th>
<td className="px-3 py-1 text-right">{quote.price.toFixed(2)}</td>
</tr>
)
})
export default function RowStoreDeltasDemo() {
const [store, setStore] = useState(seeded)
const ids = useRowIds(store)
const meta = useStoreMeta(store)
const receive = () => store.applyDeltas({
upsert: [{ id: "GAMMA", price: 101.25 }],
patch: [{ id: "ALPHA", fields: { price: 99.75 } }],
remove: ["BETA"],
meta: { dropped: 3 },
})
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={meta.version !== 1} onClick={receive}>Apply feed batch</button>
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={meta.version !== 2} onClick={() => store.applyDeltas({ meta: { dropped: 2 } })}>Report two more drops</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setStore(seeded())}>Reset batch example</button>
</div>
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="overflow-auto rounded border">
<table className="w-full" aria-label="Batch quotes">
<thead className="text-muted-foreground"><tr><th scope="col" className="px-3 py-1 text-left font-medium">Symbol</th><th scope="col" className="px-3 py-1 text-right font-medium">Price</th></tr></thead>
<tbody>{ids.map((id) => <QuoteRow key={id} store={store} id={id} />)}</tbody>
</table>
</div>
<p role="status" className="max-w-64 text-muted-foreground">Lane: {meta.lane}. Batch {meta.version}. Rows: {meta.size}. Dropped: {meta.dropped}.</p>
</div>
</>
)
}
Batching individual messages
Use createFrameBatcher when messages arrive one at a time. The Queue three messages button sends two price patches around a size patch. The next animation frame applies one batch: the later price wins, and the size field is retained. The first burst produces a price of 100.03 and size 200.
Messages received counts every queued message; Batches applied excludes the seed. Multiple bursts before the same frame share one batch. Queue then cancel discards the queued batch without changing the store. The next burst continues the sample feed's prices, so canceled values are skipped. Cleanup also calls cancel() on unmount. Call flush() when queued work must apply immediately; an already-batched feed should call applyDeltas directly.
import { useEffect, useRef, useState } from "react"
import { useRow, useStoreMeta } from "@/hooks/use-row-store"
import { createFrameBatcher, createRowStore } from "@/lib/row-store"
interface Quote { id: string; price: number; size: number }
export default function RowStoreBatchingDemo() {
const [store] = useState(() => {
const store = createRowStore<Quote>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: [{ id: "ALPHA", price: 100, size: 100 }] })
return store
})
const [batcher] = useState(() => createFrameBatcher(store.applyDeltas, { getRowId: store.getRowId }))
useEffect(() => () => batcher.cancel(), [batcher])
const nextPrice = useRef(100)
const [received, setReceived] = useState(0)
const quote = useRow(store, "ALPHA")!
const meta = useStoreMeta(store)
const receive = (cancel: boolean) => {
const price = nextPrice.current
nextPrice.current += 0.03
batcher.push({ patch: [{ id: "ALPHA", fields: { price: price + 0.01 } }] })
batcher.push({ patch: [{ id: "ALPHA", fields: { size: 200 } }] })
batcher.push({ patch: [{ id: "ALPHA", fields: { price: price + 0.03 } }] })
setReceived((count) => count + 3)
if (cancel) batcher.cancel()
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1" onClick={() => receive(false)}>Queue three messages</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => receive(true)}>Queue then cancel</button>
</div>
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="overflow-auto rounded border">
<table className="w-full" aria-label="Batched quote">
<thead className="text-muted-foreground"><tr><th scope="col" className="px-3 py-1 text-left font-medium">Symbol</th><th scope="col" className="px-3 py-1 text-right font-medium">Price</th><th scope="col" className="px-3 py-1 text-right font-medium">Size</th></tr></thead>
<tbody><tr className="border-t"><th scope="row" className="px-3 py-1 text-left font-medium">{quote.id}</th><td className="px-3 py-1 text-right">{quote.price.toFixed(2)}</td><td className="px-3 py-1 text-right">{quote.size}</td></tr></tbody>
</table>
</div>
<p role="status" className="max-w-64 text-muted-foreground">Messages received: {received}. Batches applied: {meta.version - 1}.</p>
</div>
</>
)
}
Sorted and filtered views
This view includes nonnegative changes, highest first, while the store retains insertion order. BETA starts below zero and is hidden. Hold and raise BETA calls touch() before changing it to +0.03: it appears at the end during the two-second hold, then moves to the top without another feed update. Reset restores the original values for another pass.
Static options stay at module scope. useView replaces the view when its store or options change and disposes the active view on unmount. The table uses the view's ids and subscribes to each row in the underlying store.
import { memo, useState } from "react"
import { useRow, useRowIds, useView } from "@/hooks/use-row-store"
import { createRowStore, type RowStore, type ViewOptions } from "@/lib/row-store"
interface Quote { id: string; change: number }
const quotes: Quote[] = [{ id: "ALPHA", change: 0.02 }, { id: "BETA", change: -0.01 }, { id: "GAMMA", change: 0.01 }]
const options: ViewOptions<Quote> = { comparator: (a, b) => b.change - a.change, filter: (quote) => quote.change >= 0, reorderHoldMs: 2000 }
const QuoteRow = memo(function QuoteRow({ store, id }: { store: RowStore<Quote>; id: string }) {
const quote = useRow(store, id)
if (!quote) return null
return (
<tr className="border-t">
<th scope="row" className="px-3 py-1 text-left font-medium">{id}</th>
<td className="px-3 py-1 text-right">{quote.change > 0 ? "+" : ""}{quote.change.toFixed(2)}</td>
</tr>
)
})
export default function RowStoreViewsDemo() {
const [store] = useState(() => {
const store = createRowStore<Quote>({ getRowId: (quote) => quote.id })
store.applyDeltas({ upsert: quotes })
return store
})
const view = useView(store, options)!
const ids = useRowIds(view)
const storeIds = useRowIds(store)
const beta = useRow(store, "BETA")!
const raise = () => {
view.touch()
store.applyDeltas({ patch: [{ id: "BETA", fields: { change: 0.03 } }] })
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={beta.change >= 0} onClick={raise}>Hold and raise BETA</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => store.applyDeltas({ upsert: quotes })}>Reset view example</button>
</div>
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="overflow-auto rounded border">
<table className="w-full" aria-label="Nonnegative changes, highest first">
<thead className="text-muted-foreground"><tr><th scope="col" className="px-3 py-1 text-left font-medium">Symbol</th><th scope="col" className="px-3 py-1 text-right font-medium">Change</th></tr></thead>
<tbody>{ids.map((id) => <QuoteRow key={id} store={store} id={id} />)}</tbody>
</table>
</div>
<p className="max-w-64 text-muted-foreground">Store order: {storeIds.join(", ")}</p>
<p role="status" className="max-w-64 text-muted-foreground">View order: {ids.join(", ")}</p>
</div>
</>
)
}
API Reference
You own the feed and call applyDeltas once per frame. useRow(store, id) subscribes to that row's snapshot; unrelated updates leave its object identity unchanged. React batches a call's notifications into one render pass over the affected rows.
Choose the path that matches your feed:
- Already batched per frame: call
applyDeltasfrom the event listener, as with a desktop app's native process. Another animation-frame wait adds latency. - One message at a time: push deltas into
createFrameBatcher(store.applyDeltas, { getRowId }), as with WebSocket or polling. It coalesces row writes, merging patch fields with later values winning, and applies the result on the next animation frame.
Views
store.createView(options) makes a sorted, filtered id list. useRowIds(view) returns the same array until its ids or their order change. All view options are optional:
| Option | Type | Default | Purpose |
|---|---|---|---|
comparator |
(a: T, b: T) => number |
Store order | Sort rows by your prioritization rule. |
filter |
(row: T) => boolean |
All rows | Include rows that pass. |
reorderHoldMs |
number |
0 |
Hold duration in milliseconds after touch(); 0 disables it. |
now |
() => number |
Date.now |
Clock in milliseconds for the hold. |
Grid key and pointer handling calls view.touch() to start or extend the hold. Remaining rows keep their relative order; new matches append, while removed rows and rows that fail the filter disappear. When the hold expires, the view settles to the comparator's order (or store order), even on a quiet feed.
Use useView(store, options) when a component owns its view. Keep options stable with useMemo: a new options object or store triggers replacement in an effect. null options replace the view with null. The hook disposes replaced views and cleans up on unmount. It also recreates a view disposed by StrictMode's development mount rehearsal; a memo with a cleanup effect alone would keep returning that disposed view.
For a manually created view, call view.dispose() when finished. view.isDisposed() then returns true, and the view stops following the store.
Meta
useStoreMeta(store) returns a new snapshot for each batch, including empty batches, and for clear(). Producer fields omitted from applyDeltas metadata retain their previous values.
| Field | Type | Initial value | Meaning |
|---|---|---|---|
version |
number |
0 |
Increments per batch or clear(). |
size |
number |
0 |
Current row count. |
lane |
"coalesced" | "ordered" |
Store option, otherwise "coalesced" |
Latest reported lane. |
dropped |
number |
0 |
Cumulative producer drop count. |
seq |
number | null |
null |
Last supplied sequence number. |
gap |
boolean |
false |
Producer reports a gap with replay in progress. |
lastBatchAt |
number | null |
null |
Store clock at the last batch or clear(), milliseconds since epoch. |
producedAt |
number | null |
null |
Last supplied producer timestamp, milliseconds since epoch. |
Map this metadata to a FeedDescriptor for the feed-health strip.
What it does not do
The store owns rows and subscriptions. Fetching, reconnection, IPC, persistence, and schema validation are yours. The store itself runs no animation-frame loop; the optional batcher schedules queued work.
This page is docs/row-store.md at v1.4.13.