Docs · Integrations

TanStack Virtual + AT-1

Render a table with millions of rows in React from a single compressed .at1 file — no database, no API server, and only a fraction of the file ever read. AT-1 and TanStack Virtual line up almost perfectly.

Live — scroll it. 1,000,000 rows, decoded on demand, entirely in your browser:

Live · 1,000,000 rowsfrom one .at1row-groups decoded: 0 / 62 100% in your browser
idpriceqtytime
Loading the decoder & sample…
Scroll fast: the decoded-groups counter only climbs as you reach new regions — AT-1 decodes a row-group the first time you scroll into it, then caches it. No backend, no database; the whole table is this one file.

Why they complement each other

TanStack Virtual:  renders only the rows currently on screen (a moving window).
AT-1:              stores the table compressed in row-groups and decodes only the
                   group a row lives in — reading a fraction of the file, no server.
Together:          the virtualizer's visible range -> the exact row-groups to decode.
                   A million-row table scrolls smoothly from one static .at1 file.

AT-1 stores a table as compressed row-groups, each with a zone-map and an integrity seal. That row-group is the natural unit of virtualization: the visible window touches one or two groups, so you decode one or two groups — not the file. The “compression” is table stakes; the point here is that the format is addressable, so a front-end can pull exactly the slice it's about to paint.

Install

npm install @tanstack/react-virtual
# plus the AT-1 query WASM (at1_block.js + at1_block.wasm — the build that powers /try).
# Serve them from /public/wasm and load at1_block.js once; it registers window.AT1Module.

1 · A 40-line wrapper over the AT-1 query WASM

Open the file, read its schema, and decode a single row-group on demand (cached). This is the same WASM that powers the /try demo.

// at1-rows.ts — a tiny wrapper over the AT-1 block-query WASM.
// Opens a queryable .at1, reads its schema, and decodes ONE row-group on demand
// (cached). Pure client-side: the file never leaves the browser.
let M: any;

export type At1Table = {
  h: number; ncols: number; nrg: number; total: number;
  types: string[]; rowsPerGroup: number[]; starts: number[];
};

export async function openAt1(url: string): Promise<At1Table> {
  M ??= await (window as any).AT1Module({ locateFile: (f: string) => `/wasm/${f}` });
  const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
  M.FS.writeFile("/in.at1", bytes);
  const h = M.ccall("at1q_open", "number", ["string"], ["/in.at1"]);
  if (!h) throw new Error("Not a queryable .at1. Encode with: at1 compress qcolumnar " +
    "data.csv out.at1 --backend zstd --block-backend zstd --keep-queryable");
  const ncols = M.ccall("at1q_ncols", "number", ["number"], [h]);
  const nrg   = M.ccall("at1q_nrowgroups", "number", ["number"], [h]);
  const total = M.ccall("at1q_total_rows", "number", ["number"], [h]);
  const types = Array.from({ length: ncols }, (_, c) =>
    ["text", "int", "dec"][M.ccall("at1q_coltype", "number", ["number","number"], [h, c])]);
  const rowsPerGroup: number[] = [], starts: number[] = [];
  let acc = 0;
  for (let g = 0; g < nrg; g++) {
    const r = M.ccall("at1q_rows_in_group", "number", ["number","number"], [h, g]);
    starts.push(acc); rowsPerGroup.push(r); acc += r;
  }
  return { h, ncols, nrg, total, types, rowsPerGroup, starts };
}

// group decode cache: groupIndex -> { colIndex -> BigInt64Array }
const cache = new Map<number, Record<number, BigInt64Array>>();

export function getRow(t: At1Table, i: number): (bigint | null)[] {
  // which row-group owns global row i? (binary search the prefix sums)
  let lo = 0, hi = t.nrg - 1, g = 0;
  while (lo <= hi) { const m = (lo + hi) >> 1; if (t.starts[m] <= i) { g = m; lo = m + 1; } else hi = m - 1; }
  const local = i - t.starts[g];
  let cols = cache.get(g);
  if (!cols) {
    cols = {};
    const cap = t.rowsPerGroup[g];
    for (let c = 0; c < t.ncols; c++) {
      if (t.types[c] !== "int") continue;            // in-browser WASM decodes INTEGER columns
      const buf = M._malloc(8 * cap);
      const n = M.ccall("at1q_decode_int", "number",
        ["number","number","number","number","number"], [t.h, g, c, buf, cap]);
      const arr = new BigInt64Array(cap);
      for (let k = 0; k < n; k++) arr[k] = M.getValue(buf + 8 * k, "i64");
      M._free(buf);
      cols[c] = arr;
    }
    cache.set(g, cols);                              // decode each group at most once
  }
  return t.types.map((ty, c) => (ty === "int" ? cols![c][local] : null));
}

2 · Feed it to useVirtualizer

count is the full row total; the virtualizer only ever calls getRow for on-screen indices, so AT-1 only decodes the groups you can see.

// At1VirtualTable.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { openAt1, getRow, type At1Table } from "./at1-rows";

export function At1VirtualTable({ url }: { url: string }) {
  const [t, setT] = useState<At1Table | null>(null);
  const parentRef = useRef<HTMLDivElement>(null);
  useEffect(() => { openAt1(url).then(setT); }, [url]);

  const rv = useVirtualizer({
    count: t?.total ?? 0,                 // could be millions
    getScrollElement: () => parentRef.current,
    estimateSize: () => 32,
    overscan: 16,
  });

  if (!t) return <div>Loading…</div>;
  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: rv.getTotalSize(), position: "relative" }}>
        {rv.getVirtualItems().map((vi) => {
          const row = getRow(t, vi.index);  // decodes only this row's group (then cached)
          return (
            <div key={vi.key}
              style={{ position: "absolute", top: 0, left: 0, width: "100%",
                       height: vi.size, transform: `translateY(${vi.start}px)`,
                       display: "grid", gridTemplateColumns: `repeat(${t.ncols}, 1fr)` }}>
              {row.map((cell, c) => <span key={c}>{cell?.toString() ?? "·"}</span>)}
            </div>
          );
        })}
      </div>
    </div>
  );
}
Two honest constraints for the pure-browser path. (1) The in-browser WASM decodes integer columns (text/decimal come back as null here) — for full mixed-type rows use the SQL-window path below. (2) Encode with the fast-decode profile so the WASM can read the blocks: --backend zstd --block-backend zstd --keep-queryable (xz blocks need the native decoder, not WASM).

Any column type, or a remote file: window through SQL

For text-heavy tables or a .at1sitting in S3, skip client decode and let the virtualizer drive a windowed query — full rows, still only the touched blocks read. Pair this with TanStack Virtual's range / onChange.

// Any column types, or a remote file? Window through AT-1's SQL endpoint instead.
// TanStack Virtual gives you the visible range; fetch exactly that slice.
async function fetchWindow(startRow: number, endRow: number) {
  const r = await fetch("https://your-at1-cloud/sql", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: "Bearer <token>" },
    body: JSON.stringify({
      sql: `SELECT * FROM data/table.csv WHERE id BETWEEN ${startRow} AND ${endRow}`,
    }),
  });
  return (await r.json()).rows;   // only the touched blocks are read server-side
}

What this buys you

  • No backend to stand up. One static file on a CDN; the browser does the rest.
  • Constant memory. Decoded groups are cached and bounded; the DOM holds only visible rows (TanStack Virtual) and RAM holds only visited groups (AT-1).
  • Verified + addressable. Same file is byte-exact and integrity-sealed — the data you render is provably the data you stored.