Viewport hook
Load blocks around the current scroll position without allocating the full dataset. useCominsViewport owns consumer React state; the Table requests the required ranges.
Live example
Section titled “Live example”import { useMemo, useRef, useState } from 'react';import { CominsTable, reduceCominsViewportData, useCominsViewport, type CominsTableColumn, type CominsViewportRequest } from 'comins-table';import 'comins-table/styles.css';
type Row = { id: number; name: string; amount: number };const rowCount = 50_000;
export default function TableViewportDemo({ lang = 'ko' }: { lang?: string }) { const ko = lang === 'ko'; const [revision, setRevision] = useState(0); const [descending, setDescending] = useState(false); const [events, setEvents] = useState<string[]>([]); const edits = useRef(new Map<number, Row>()); const failNext = useRef(false); const record = (text: string) => setEvents(previous => [...previous.slice(-7), text]); const columns = useMemo<CominsTableColumn<Row>[]>(() => [ { field: 'id', label: 'ID', width: 110 }, { field: 'name', label: ko ? '이름' : 'Name', width: 220 }, { field: 'amount', label: ko ? '수량' : 'Amount', width: 130 }, ], [ko]); const viewport = useCominsViewport<Row>({ rowCount, queryKey: `${descending ? 'desc' : 'asc'}:${revision}`, blockSize: 50, cacheSize: 3, maxConcurrentRequests: 2, getRows: async ({ startIndex, endIndex, signal }: CominsViewportRequest) => { const range = `${startIndex}–${endIndex}`; record(`request ${range}`); // Simulated remote latency; replace this block with fetch(url, { signal }). await new Promise<void>((resolve, reject) => { if (signal.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } const abort = () => { clearTimeout(timer); record(`cancel ${range}`); reject(new DOMException('Aborted', 'AbortError')); }; const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, 400); signal.addEventListener('abort', abort, { once: true }); }); if (failNext.current) { failNext.current = false; record(`error ${range}`); throw new Error('Demo request failure'); } const rows = Array.from({ length: endIndex - startIndex }, (_, offset) => { const index = startIndex + offset; const id = descending ? rowCount - index : index + 1; return edits.current.get(id) ?? { id, name: `${ko ? '항목' : 'Item'} ${id}`, amount: id * 10 }; }); record(`success ${range}`); return rows; }, onEdit: changes => { // The app retains edits outside the block cache. No server write is made here. changes.forEach(({ row }) => edits.current.set(row.id, row)); record(`edit ${changes.map(change => change.index).join(',')}`); }, }); const first = viewport.data.blocks[0]; const editFirst = () => { const row = first?.rows[0]; if (!first || !row) return; viewport.tableProps.onChangeData(reduceCominsViewportData(viewport.data, { type: 'patch', changes: [{ index: first.startIndex, row: { ...row, amount: row.amount + 1 } }], })); }; return <div className="demo" data-demo="table-viewport"> <div className="demo-toolbar"> <button onClick={() => setDescending(value => !value)}>{ko ? '순서 전환' : 'Reverse order'}</button> <button onClick={() => setRevision(value => value + 1)}>{ko ? '조회 갱신' : 'Reload query'}</button> <button onClick={() => { failNext.current = true; setRevision(value => value + 1); }}>{ko ? '오류 재현' : 'Simulate error'}</button> <button disabled={!first?.rows.length} onClick={editFirst}>{ko ? '첫 캐시 행 +1' : 'First cached row +1'}</button> </div> <p className="demo-status">{ko ? '50,000행 · 범위 조회 시뮬레이션 · 블록 밖에서도 편집 보존' : '50,000 rows · simulated range loading · edits retained outside the cache'}</p> <div className="table-viewport" style={{ height: 320 }}> <CominsTable {...viewport.tableProps} columns={columns} getRowId={row => row.id} rowHeight={36} /> </div> <p className="demo-status" data-viewport-stats> {ko ? '조회' : 'Query'} {viewport.data.revision} · {ko ? '캐시' : 'Cached'} {viewport.data.blocks.reduce((sum, block) => sum + block.rows.length, 0)} / {rowCount} · {ko ? '요청' : 'Requests'} {viewport.data.requests.length} </p> <details open><summary>{ko ? '요청·편집 기록' : 'Request & edit log'}</summary><pre data-viewport-events>{events.join('\n')}</pre></details> <details><summary>{ko ? '캐시 상태' : 'Cache state'}</summary><pre data-viewport-state>{JSON.stringify({ revision: viewport.data.revision, blocks: viewport.data.blocks.map(block => ({ startIndex: block.startIndex, count: block.rows.length, first: block.rows[0] })), requests: viewport.data.requests, editCount: edits.current.size }, null, 2)}</pre></details> </div>;}useCominsViewport
Section titled “useCominsViewport”export declare function useCominsViewport<T>(options: CominsViewportOptions<T>): { data: CominsViewportData<T>; tableProps: { data: CominsViewportData<T>; onChangeData: (next: CominsViewportData<T>) => void; onViewportRequest: (request: CominsViewportRequest) => Promise<void>; viewportDatasource: CominsViewportDatasource; virtualized: true; };};data is the current snapshot. Spread tableProps into CominsTable and supply columns and a stable getRowId. tableProps contains data, onChangeData, onViewportRequest, viewportDatasource and virtualized: true. Overriding these callbacks requires managing the controlled state yourself.
Changing queryKey, rowCount, blockSize or cacheSize creates a new hook snapshot. Change queryKey when the query order or filters change. Replacing the getRows function alone does not reset the dataset. Aborted requests and responses from an earlier initial state are ignored; fetch exceptions become error request states.
CominsViewportOptions
Section titled “CominsViewportOptions”export type CominsViewportOptions<T> = { rowCount: number; queryKey: CominsViewportRevision; getRows: (request: CominsViewportRequest) => readonly T[] | Promise<readonly T[]>; blockSize?: number; cacheSize?: number; maxConcurrentRequests?: number; heightCacheSize?: number; onEdit?: (changes: readonly CominsViewportPatch<T>[]) => void;};| Property | Behavior / default |
|---|---|
rowCount |
Required non-negative safe integer for the total row count. Resolve the count before enabling this mode. |
queryKey |
Required string/number dataset revision. Change it when search, sorting or index order changes. |
getRows |
Required range loader receiving [startIndex, endIndex) and signal. Return exactly endIndex − startIndex non-null, defined rows, including the final block. |
blockSize |
Defaults to 100 rows. Invalid positive-safe-integer input falls back to the default. |
cacheSize |
Defaults to 12 blocks. Protected visible and active-request blocks can exceed this target. |
maxConcurrentRequests |
Defaults to 2 concurrent Table range requests. Invalid positive-safe-integer input falls back to 2. |
heightCacheSize |
Defaults to 64 blocks for measured row heights, separate from the data cache. |
onEdit |
Optional notification with absolute-index/new-row patches, called synchronously before the local snapshot update. The app persists edits and merges unsaved values into later loads after eviction. |
Capabilities
Section titled “Capabilities”Supports fixed/automatic row height, column pinning/resizing and editing loaded rows. Do not combine with Tree, Row Grouping, Row Detail, row moves, append loading or pagination. Apply sorting/filtering through external query conditions. Server push and automatic loading for unloaded-row edits are not provided.