@doync/query-virtualizer
v0.4.0
Published
doync React binding: virtualized infinite lists over Subscriptions via @rocicorp/zero-virtual/core
Maintainers
Readme
@doync/query-virtualizer
React binding for virtualized infinite lists over doync Subscriptions. Stage factories return a Bound query; the hook keeps three live useQuery slots (main page, after page, single-row) filled as you scroll, and renders only the visible window. Built on @rocicorp/zero-virtual /core only — consumers do not declare or import zero-virtual themselves. This package's root export is the stable surface; deeper experimental core symbols stay on @rocicorp/zero-virtual/core and may move with that pin.
Use it under a @doync/react <DoyncProvider> with registered queries that support keyset (or equivalent) pagination.
Install
pnpm add @doync/query-virtualizer| Package | Role |
| --- | --- |
| @doync/query-virtualizer | This binding |
| @doync/react, @doync/core | Direct dependencies (pulled in transitively — import DoyncProvider / hooks from @doync/react as usual) |
| react | Peer — >=18 |
@rocicorp/zero-virtual is a direct dependency of this package pinned for you; do not add it to the app unless you intentionally opt into /core experimental APIs.
Quick sketch
An overflow-element list. Factories that do not close over props can live at module scope so their identity stays stable across renders (a fresh estimateSize / getRowKey / factory each render resets paging).
import {
rowAttributes,
useQueryVirtualizer,
type VirtualRow,
} from '@doync/query-virtualizer'
import { queries } from './shared/data'
import { useCallback, useRef } from 'react'
type Task = { id: string; title: string; modified: number }
type TaskStart = { id: string; modified: number }
const estimateSize = () => 48
const getRowKey = (row: Task) => row.id
const toStartRow = (row: Task): TaskStart => ({
id: row.id,
modified: row.modified,
})
const getSingleQuery = ({ id }: { id: string; settled: boolean }) => ({
query: queries.taskById(id),
})
export function TaskList({ ownerId }: { ownerId: string }) {
const listRef = useRef<HTMLDivElement>(null)
const getScrollElement = useCallback(() => listRef.current, [])
const getPageQuery = useCallback(
({
limit,
start,
dir,
}: {
limit: number
start: TaskStart | null
dir: 'forward' | 'backward'
settled: boolean
}) => ({
// Bound query — registry leaf + args. Optional `options` (ttl, skip, …)
// is the per-stage UseQueryOptions bag.
query: queries.taskPage({ ownerId, limit, start, dir }),
}),
[ownerId],
)
const { items, spaceBefore, spaceAfter, status } = useQueryVirtualizer<
string,
Task,
TaskStart
>({
listContextParams: ownerId, // any stable context; change resets the list
getScrollElement,
estimateSize,
getRowKey,
getPageQuery,
getSingleQuery,
toStartRow,
})
return (
<div
ref={listRef}
style={{ height: 480, overflow: 'auto', position: 'relative' }}
>
{/* Spacers are sibling elements — not padding on the scroller —
so native scroll anchoring can compensate. */}
<div style={{ height: spaceBefore }} />
{items.map((item) => (
<TaskRow key={item.key} item={item} />
))}
<div style={{ height: spaceAfter }} />
{status.status !== 'complete' ? <div>Loading…</div> : null}
</div>
)
}
function TaskRow({ item }: { item: VirtualRow<Task> }) {
const { index, key, row } = item
if (row === undefined) {
return (
<div {...rowAttributes(index, key)} style={{ height: 48 }}>
…
</div>
)
}
return (
<div {...rowAttributes(index, key)} style={{ height: 48 }}>
{row.title}
</div>
)
}Window-scrolled lists use useQueryWindowVirtualizer with the same options: getScrollElement then returns the element rows render into (normal page flow); the window is the scroll container.
Public API
Hooks
useQueryVirtualizer(options) → QueryVirtualizerResult
Virtualized, infinitely-paginated list that scrolls inside an overflow element. getScrollElement returns that element (also where rows render).
useQueryWindowVirtualizer(options) → QueryVirtualizerResult
Same contract, window as the scroll container.
Both take UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>:
| Option | Required | Notes |
| --- | --- | --- |
| listContextParams | yes | Opaque list context. A value change (by the core's comparison) resets paging / window. |
| getScrollElement | yes | () => HTMLElement \| null — overflow scroller, or the rows host for the window variant. |
| estimateSize | yes | (index) => number — estimated row height in px. Keep referentially stable. |
| getRowKey | yes | (row) => RowKey — stable id per data row. |
| getPageQuery | yes | GetPageQuery — see Stage factories. |
| getSingleQuery | yes | GetSingleQuery — one-row Bound query for permalink / anchor rows. |
| toStartRow | yes | (row) => TStartRow — maps a data row to the keyset cursor getPageQuery receives as start. |
| overscan | no | Extra rows rendered past the viewport (default from zero-virtual core). |
| count | no | Known total row count when the app already has one; otherwise the composition estimates. |
| anchoring | no | AnchoringMode — e.g. 'native' for comment-style natural-height lists. |
| minPageSize | no | Floor on rows requested per page (default 50; lower for tall cards). |
| settleTime | no | Idle ms before the list is considered settled (default 2000). |
| onSettled | no | Fired when the list has been idle for settleTime. |
| permalinkID | no | Row id to scroll into view once present. |
| scrollState / onScrollStateChange | no | Persist / restore window (ScrollHistoryState). Pair with useRestoreScrollState or your router. |
| observeElementRect / observeElementOffset | no | Override default observers (element vs window defaults are filled in per entry). |
QueryVirtualizerResult<TRow> is the zero-virtual binding result without the upstream complete boolean, plus aggregated doync status:
| Field | Notes |
| --- | --- |
| items | VirtualRow<TRow>[] currently on screen (and overscan). row may be undefined while a slot is still loading. |
| spaceBefore / spaceAfter | Spacer heights in px — render as sibling elements, not scroller padding. |
| status | Aggregated ViewStatus from live stages (unknown → complete / error). Replaces upstream complete. |
| rowsEmpty | True when the composition has no data rows. |
| permalinkNotFound | True when a permalinkID could not be resolved. |
| estimatedTotal / total | Size hints from the composition. |
| settled | True after the list has been idle for settleTime. |
| scrollElement | Live HTMLElement \| null — the resolved scroller (null until mounted). |
| options | Bound scroll/observer accessors (used by usePinToBottom). |
| rowAt | (index) => TRow \| undefined — data row at a virtual index. |
usePinToBottom(virtualizer, options?)
Chat/log follow: when content grows, keep the viewport pinned to the bottom — only if the user was already parked there. Pass the result of useQueryVirtualizer / useQueryWindowVirtualizer.
import {
usePinToBottom,
useQueryWindowVirtualizer,
} from '@doync/query-virtualizer'
const result = useQueryWindowVirtualizer({/* … */})
usePinToBottom(result)
// usePinToBottom(result, { enabled: true, slack: 8 })PinToBottomOptions: { enabled?: boolean; slack?: number }.
useRestoreScrollState(key?) → [scrollState, setScrollState]
Persist virtualizer scroll under a key in window.history.state (Navigation API). Pass the tuple to scrollState / onScrollStateChange. Default key is "scrollState". Requires the Navigation API (Firefox 147+); older browsers should wire a different persist layer (zbugs uses wouter glue).
const [scrollState, setScrollState] = useRestoreScrollState<TaskStart>('tasks')
useQueryVirtualizer({
/* … */
scrollState,
onScrollStateChange: setScrollState,
})Stage factories
Each stage returns a QueryResult — a Bound query plus optional per-stage useQuery options (ttl, consumer skip, …). Args ride inside the bound value. A null/absent stage from the core is expressed as a falsy query into useQuery, not a placeholder swap.
import type {
GetPageQuery,
GetSingleQuery,
QueryResult,
} from '@doync/query-virtualizer'
// GetPageQueryOptions: { limit, start, dir, settled }
const getPageQuery: GetPageQuery<Task, TaskStart> = ({
limit,
start,
dir,
}) => ({
query: queries.taskPage({ limit, start, dir }),
// options: { ttl: 'none' },
})
// GetSingleQueryOptions: { id, settled }
const getSingleQuery: GetSingleQuery<Task> = ({ id }) => ({
query: queries.taskById(id),
})| Type | Shape |
| --- | --- |
| QueryResult<Row, One> | { query: BoundQuery<Row, One>; options?: UseQueryOptions } |
| GetPageQuery<TRow, TStartRow> | (GetPageQueryOptions<TStartRow>) => QueryResult<TRow, false> |
| GetSingleQuery<TRow> | (GetSingleQueryOptions) => QueryResult<TRow, true> |
| GetPageQueryOptions / GetSingleQueryOptions | Re-exported from @rocicorp/zero-virtual/core |
Helpers and shared types
| Symbol | Role |
| --- | --- |
| rowAttributes(index, key) | Spread onto each virtual row element so measurement / anchoring can find it. |
| VirtualRow<TRow> | { index, key, row?: TRow, … } — one entry in items. |
| RowKey | Stable row identity type (string \| number). |
| AnchoringMode | Scroll-anchoring strategy. |
| ScrollHistoryState<TStartRow> | Persisted scroll blob for restore. |
| ViewStatus | Re-export from @doync/react — aggregated stage status on the result. |
Public surface
The main entry (.) is governed by semver. Documented public symbols:
- Runtime:
useQueryVirtualizer,useQueryWindowVirtualizer,usePinToBottom,useRestoreScrollState,rowAttributes - Types:
UseQueryVirtualizerOptions,QueryVirtualizerResult,QueryResult,GetPageQuery,GetSingleQuery,GetPageQueryOptions,GetSingleQueryOptions,PinToBottomOptions,VirtualRow,RowKey,AnchoringMode,ScrollHistoryState,ViewStatus
Internal (@doync/query-virtualizer/internal)
Anything imported from @doync/query-virtualizer/internal is not part of the semver surface. It may change or disappear in any release, including patches, without notice. Sibling @doync/* packages reach internals through that specifier when they must; application code should not. Import the virtualizer hooks from @doync/query-virtualizer.
