Lattice Grid Buy a licence

react data grid · server-side with duckdb

React Grid: Server-Side DuckDB

createPushdownSource plus duckdbAdapter push the grid's filter, group and total work down into a real analytical engine instead of computing over rows already sitting on screen, and fullDataset says the subtotal is over the whole matching set, not one page of it.

Also in: Angular

Install and import

npm install @toclocoinc/lattice-grid
# DuckDB itself loads from a CDN at runtime, not from this package
# free on localhost; a production domain needs a licence key (see /pricing/)

The files

lattice.ts

// lattice.ts
import React from 'react';
import * as Lattice from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react';

export const { createGrid, createPushdownSource, duckdbAdapter } = Lattice;
export const LatticeGrid = createLatticeGrid({ React, createGrid });

Readings.tsx

// Readings.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import * as Lattice from '@toclocoinc/lattice-grid';
import { LatticeGrid, createPushdownSource, duckdbAdapter } from './lattice';

// The share of a group's rows inside tolerance. Under fullDataset the scope
// is the whole group, so a subtotal is a whole-group rate, not a page's.
const inSpecRate = (values: unknown[]) => {
  let seen = 0, ok = 0;
  for (const v of values) { if (typeof v === 'boolean') { seen++; if (v) ok++; } }
  return seen ? ok / seen : null;
};

const columns = [
  { field: 'plant', title: 'Plant', filter: { type: 'set' }, group: { enabled: true, index: 0 }, total: 'count' },
  { field: 'line', title: 'Line', filter: { type: 'set' } },
  { field: 'product', title: 'Product' },
  { field: 'bore_mm', title: 'Bore', type: 'millimetres', spec: { lower: 9.95, upper: 10.05, target: 10 }, total: 'avg' },
  { field: 'cycle_seconds', title: 'Cycle', type: 'seconds', total: 'avg' },
  { id: 'in_spec_rate', field: 'in_spec', title: 'In-spec rate', type: 'number', format: { style: 'percent', decimals: 2 }, total: inSpecRate },
  { field: 'taken_at', title: 'Taken', type: 'dateString' },
];

function Readings() {
  const [source, setSource] = React.useState<any>(null);
  const [status, setStatus] = React.useState('Starting DuckDB in the browser...');

  // DuckDB is your own engine, loaded from a CDN; the grid imports none of
  // it. duckdbAdapter takes the connection you made, so the source drives a
  // full analytical engine while the grid package stays dependency-free.
  React.useEffect(() => {
    let disposed = false;
    let connection: any, worker: any;
    (async () => {
      try {
        setStatus('Loading DuckDB from a CDN...');
        const duckdb = await import('https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm');
        const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
        worker = await duckdb.createWorker(bundle.mainWorker);
        const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(duckdb.LogLevel.ERROR), worker);
        await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
        if (disposed) return;
        connection = await db.connect();

        // The engine resolves the URL, so it must be absolute.
        const file = new URL('/demo-data/readings.parquet', location.origin).href;
        const adapter = duckdbAdapter({ connection, from: `read_parquet('${file}')` });

        // fullDataset pulls the whole matching set in once and holds it, so
        // the subtotals and the grand total are over every matching row, not
        // one page of them.
        setSource(createPushdownSource({
          adapter, compute: Lattice, pageSize: 200,
          fullDataset: { enabled: true, maxRows: 250000 },
        }));
        setStatus('Rows pulled into the tab and grouped by plant.');
      } catch {
        setStatus('DuckDB could not start here: it needs WebAssembly and a CDN connection.');
      }
    })();
    return () => {
      disposed = true;
      try { connection?.close?.(); } catch {}
      try { worker?.terminate?.(); } catch {}
    };
  }, []);

  return (
    <>
      <div style={{ fontSize: 13, color: 'var(--ink-2)' }}>{status}</div>
      {source && (
        <LatticeGrid
          rowKey="reading_id"
          toolPanel={{ side: 'left', panels: ['filters', 'columns'] }}
          grandTotalRow="bottom"
          groupFooter
          showTotalInHeader
          columns={columns}
          source={source}
          style={{ height: '520px', marginTop: '12px' }}
        />
      )}
    </>
  );
}

createRoot(document.getElementById('readings')!).render(<Readings />);

See it running

Building…
Loading a live grid…

Open this demo's React tab →

Working in React

The source is state, not a ref. Building the DuckDB connection is asynchronous, so the grid is not rendered at all until source is set; there is no grid instance to reach before that, by ref or otherwise.

Clean up the connection. The effect's cleanup closes the DuckDB connection and terminates its worker, so navigating away mid-query does not leave a worker running in a tab nobody is looking at.

compute: Lattice. The pushdown source still needs the grid's own compute helpers for anything DuckDB itself is not asked to do, which is why the whole package namespace is passed in alongside the adapter.

Related