Skip to content

Query a million rows in the browser with DuckDB-Wasm

In this tutorial, you will back a Handsontable DataGrid with DuckDB-Wasm, the in-process SQL database compiled to WebAssembly. DuckDB holds a million rows in a Web Worker, and the grid holds one page at a time. Every page, sort, filter, and edit runs as SQL in the browser, with no server, no API, and no account.

Open in the demo runner

The grid above generates a million orders in DuckDB when it loads. Sort a column, filter Region or Qty, or page to the end. The status bar shows how many rows match and how long the last query took. The demo loads the DuckDB engine from jsDelivr, while this recipe serves it from your own app with Vite.

Overview

Difficulty: Intermediate
Time: ~30 minutes
Stack: React, Vite, Handsontable dataProvider, @handsontable/react-wrapper, @duckdb/duckdb-wasm 1.32

The dataProvider plugin turns Handsontable into a grid backed by a remote source. You supply four async functions — one that fetches a page of rows, and three that create, update, and delete rows — and the plugin drives pagination, sorting, and filtering through them. The other server-side recipes point those functions at an HTTP API. This one points them at a database that runs in the same browser tab.

That fits a common case: someone has a large Parquet or CSV export and no API in front of it. DuckDB reads the file in the browser, and the grid gives them sorting, filtering, and editing over all of it.

What you’ll build

An orders grid over 1,000,000 generated rows:

  • A DuckDB-Wasm database in a Web Worker, seeded in SQL with no download.
  • Page-by-page loading with a stable order, so no row is repeated or skipped between pages.
  • Sorting and filtering in SQL, with each Handsontable filter condition mapped to a parameterized SQL predicate.
  • Cell edits, new rows, and deletions written to DuckDB, with a multi-row edit committed in one transaction.
  • A status bar that shows how many rows match and how long the last query took.
  • A Load Parquet or CSV button that replaces the data with your own file, and an Export Parquet button that downloads the table, edits included.

On a recent laptop, the million rows are generated in under a second, and a sorted page returns in tens of milliseconds.

Before you begin

RequirementVersion
handsontable18.1 or later
@handsontable/react-wrapper18.1 or later
@duckdb/duckdb-wasm1.32.0
React + Viteany current version

Scaffold a Vite + React + TypeScript app if you do not have one, then add the packages:

Terminal window
npm create vite@latest orders-app -- --template react-ts
cd orders-app
npm install handsontable @handsontable/react-wrapper
npm install --save-exact @duckdb/duckdb-wasm@1.32.0

Pin @duckdb/duckdb-wasm to an exact version. The package publishes development builds under its latest tag, so a bare npm install @duckdb/duckdb-wasm can install a pre-release build. Run npm view @duckdb/duckdb-wasm dist-tags to see what each tag points to.

The recipe has seven files in src/:

FilePurpose
duckdb.tsStarts DuckDB in a Web Worker and runs statements one at a time
seed.tsGenerates the table, loads a user’s file, and exports Parquet
schema.tsThe columns that SQL is allowed to reference
filterToSql.tsTurns Handsontable filter conditions into a SQL WHERE clause
ordersProvider.tsThe four dataProvider functions
OrdersGrid.tsxThe grid component
App.tsxSeeds the data, shows the status bar, and renders the grid
  1. Start DuckDB in a Web Worker

    Create src/duckdb.ts:

    TypeScript
    import * as duckdb from '@duckdb/duckdb-wasm';
    import mvpWasm from '@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url';
    import mvpWorker from '@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url';
    import ehWasm from '@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url';
    import ehWorker from '@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url';
    // Vite serves these files from node_modules, so the app needs no CDN at runtime.
    const BUNDLES: duckdb.DuckDBBundles = {
    mvp: { mainModule: mvpWasm, mainWorker: mvpWorker },
    eh: { mainModule: ehWasm, mainWorker: ehWorker },
    };
    interface Database {
    db: duckdb.AsyncDuckDB;
    conn: duckdb.AsyncDuckDBConnection;
    }
    let databasePromise: Promise<Database> | null = null;
    // Starts DuckDB in a Web Worker once and shares one connection across the app.
    export function getDatabase(): Promise<Database> {
    databasePromise ??= (async () => {
    const bundle = await duckdb.selectBundle(BUNDLES);
    const worker = new Worker(bundle.mainWorker!);
    const db = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker);
    await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
    // COUNT(*) and nextval() return BIGINT. Cast to JS numbers so the grid never sees a BigInt.
    await db.open({ query: { castBigIntToDouble: true } });
    return { db, conn: await db.connect() };
    })();
    return databasePromise;
    }
    // The grid can start a fetch while a mutation's refetch is still pending. Running every
    // statement through one queue keeps them from interleaving on the shared connection.
    let queue: Promise<unknown> = Promise.resolve();
    export function exclusive<T>(task: (database: Database) => Promise<T>): Promise<T> {
    const run = queue.then(async () => task(await getDatabase()));
    queue = run.catch(() => undefined);
    return run;
    }
    // Runs a parameterized statement and returns plain objects. The statement is always closed.
    export async function query<T = Record<string, unknown>>(
    conn: duckdb.AsyncDuckDBConnection,
    sql: string,
    params: unknown[] = [],
    ): Promise<T[]> {
    const statement = await conn.prepare(sql);
    try {
    const table = await statement.query(...params);
    return table.toArray().map((row) => row.toJSON() as T);
    } finally {
    await statement.close();
    }
    }

    What’s happening:

    • Vite serves the engine. The ?url imports give you URLs to the worker scripts and .wasm files inside node_modules, so the app needs no CDN at runtime. selectBundle picks the exception-handling (EH) build when the browser supports it, and falls back to the MVP build otherwise. Neither build needs cross-origin isolation headers. Only DuckDB’s multithreaded build does.
    • The worker keeps the page responsive. Every query runs off the main thread, so scrolling and typing in the grid never wait on DuckDB.
    • castBigIntToDouble keeps BigInt out of the grid. COUNT(*) and sequence values are BIGINT in DuckDB, which Apache Arrow returns as JavaScript BigInt. The cast turns them into ordinary numbers in one place.
    • exclusive runs one statement at a time. The plugin can start a fetch while the refetch that follows a mutation is still pending. Queuing every call keeps a transaction from interleaving with an unrelated query on the shared connection.
    • query closes every prepared statement. A prepared statement holds memory in the worker until you close it, so the finally block closes it whether the query succeeds or fails.
  2. Generate a million rows

    Create src/seed.ts. The first function builds the orders table. The other two come into play in Step 7.

    TypeScript
    import { exclusive } from './duckdb';
    export const ROW_COUNT = 1_000_000;
    // Builds the demo table entirely in SQL: no download, works offline. hash() makes the
    // values look random but stay the same on every load.
    const SEED_SQL = `
    CREATE OR REPLACE TABLE orders AS
    SELECT
    (i + 1)::INTEGER AS id,
    'ORD-' || lpad((i + 1)::VARCHAR, 7, '0') AS order_no,
    (['Bluefin Logistics', 'Cobalt Health', 'Driftwood Retail', 'Ember Energy', 'Fjord Foods',
    'Granite Bank', 'Harbor Insurance', 'Ironleaf Pharma', 'Juniper Telecom', 'Kestrel Aerospace']
    )[1 + (hash(i * 3 + 1) % 10)::INTEGER] AS customer,
    (['North America', 'EMEA', 'APAC', 'LATAM'])[1 + (hash(i * 5 + 2) % 4)::INTEGER] AS region,
    (['Laptop', 'Monitor', 'Dock', 'Keyboard', 'Headset', 'Server rack', 'Switch', 'License'])
    [1 + (hash(i * 7 + 3) % 8)::INTEGER] AS product,
    (1 + hash(i * 11 + 4) % 500)::INTEGER AS quantity,
    round(5 + (hash(i * 13 + 5) % 199500) / 100.0, 2)::DOUBLE AS unit_price,
    (['Pending', 'Approved', 'Shipped', 'Delivered', 'Cancelled'])
    [1 + (hash(i * 17 + 6) % 5)::INTEGER] AS status,
    (DATE '2024-01-01' + (hash(i * 19 + 7) % 1035)::INTEGER) AS order_date
    FROM range(${ROW_COUNT}) AS t(i);
    CREATE OR REPLACE SEQUENCE orders_id_seq START ${ROW_COUNT + 1};
    `;
    export function seedOrders(): Promise<void> {
    return exclusive(async ({ conn }) => {
    await conn.query(SEED_SQL);
    });
    }
    // Replaces the table with a user's Parquet or CSV file. The file never leaves the browser.
    // The casts enforce the demo schema, and the grid's edits and deletes match rows by id, so
    // every id must be present and unique. The file loads into a staging table first: a file that
    // fails either check is rejected with a clear error and the current table is left as it was.
    export function loadFile(file: File): Promise<number> {
    const isParquet = file.name.toLowerCase().endsWith('.parquet');
    // A fixed internal name keeps the user's file name out of the SQL text.
    const internalName = isParquet ? 'upload.parquet' : 'upload.csv';
    const reader = isParquet ? `read_parquet('${internalName}')` : `read_csv_auto('${internalName}')`;
    return exclusive(async ({ db, conn }) => {
    await db.registerFileBuffer(internalName, new Uint8Array(await file.arrayBuffer()));
    try {
    await conn.query(`
    CREATE OR REPLACE TEMP TABLE orders_upload AS
    SELECT id::INTEGER AS id, order_no::VARCHAR AS order_no, customer::VARCHAR AS customer,
    region::VARCHAR AS region, product::VARCHAR AS product, quantity::INTEGER AS quantity,
    unit_price::DOUBLE AS unit_price, status::VARCHAR AS status, order_date::DATE AS order_date
    FROM ${reader}`);
    const check = await conn.query(`
    SELECT count(*) AS total, count(*) - count(id) AS missing_ids,
    count(id) - count(DISTINCT id) AS duplicate_ids, coalesce(max(id), 0) AS max_id
    FROM orders_upload`);
    const { total, missing_ids: missingIds, duplicate_ids: duplicateIds, max_id: maxId } = check
    .toArray()[0].toJSON() as { total: number; missing_ids: number; duplicate_ids: number; max_id: number };
    if (missingIds > 0 || duplicateIds > 0) {
    throw new Error(`every row needs a unique id (${missingIds} missing, ${duplicateIds} duplicated)`);
    }
    await conn.query('CREATE OR REPLACE TABLE orders AS SELECT * FROM orders_upload');
    await conn.query(`CREATE OR REPLACE SEQUENCE orders_id_seq START ${Math.trunc(maxId) + 1}`);
    return total;
    } finally {
    await conn.query('DROP TABLE IF EXISTS orders_upload');
    await db.dropFile(internalName);
    }
    });
    }
    // Writes the current table, edits included, to a Parquet file the user can download.
    export function exportParquet(): Promise<Blob> {
    return exclusive(async ({ db, conn }) => {
    await conn.query("COPY orders TO 'orders.parquet' (FORMAT parquet)");
    try {
    return new Blob([new Uint8Array(await db.copyFileToBuffer('orders.parquet'))], { type: 'application/vnd.apache.parquet' });
    } finally {
    await db.dropFile('orders.parquet');
    }
    });
    }

    What’s happening:

    range(1_000_000) produces a million row numbers, and each column derives its value from hash() of that number. The values look random but stay the same on every load, so a screenshot or a bug report is reproducible. Nothing is downloaded, so the demo works offline.

    CREATE OR REPLACE SEQUENCE starts the id sequence after the last generated row. New rows take their id from it, which matters in Step 5.

  3. Declare the columns SQL can reference

    Create src/schema.ts:

    TypeScript
    export type ColumnKind = 'text' | 'numeric' | 'date';
    export interface ColumnDef {
    kind: ColumnKind;
    // SQL used in SELECT and WHERE. Dates are compared as YYYY-MM-DD text, the same
    // string the grid shows, so text filter conditions behave the way users expect.
    expr: string;
    // SQL used in ORDER BY. Dates sort on the real DATE column.
    sortExpr: string;
    editable: boolean;
    }
    // The only columns the SQL builders accept. Column names can't be bound as parameters,
    // so every prop that reaches SQL is checked against this map first.
    export const COLUMNS: Record<string, ColumnDef> = {
    id: { kind: 'numeric', expr: 'id', sortExpr: 'id', editable: false },
    order_no: { kind: 'text', expr: 'order_no', sortExpr: 'order_no', editable: false },
    customer: { kind: 'text', expr: 'customer', sortExpr: 'customer', editable: true },
    region: { kind: 'text', expr: 'region', sortExpr: 'region', editable: true },
    product: { kind: 'text', expr: 'product', sortExpr: 'product', editable: true },
    quantity: { kind: 'numeric', expr: 'quantity', sortExpr: 'quantity', editable: true },
    unit_price: { kind: 'numeric', expr: 'unit_price', sortExpr: 'unit_price', editable: true },
    status: { kind: 'text', expr: 'status', sortExpr: 'status', editable: true },
    order_date: {
    kind: 'date',
    expr: "strftime(order_date, '%Y-%m-%d')",
    sortExpr: 'order_date',
    editable: true,
    },
    };
    export const SELECT_LIST = Object.entries(COLUMNS)
    .map(([prop, { expr }]) => (expr === prop ? prop : `${expr} AS ${prop}`))
    .join(', ');
    export function getColumn(prop: string): ColumnDef {
    const column = Object.hasOwn(COLUMNS, prop) ? COLUMNS[prop] : undefined;
    if (!column) {
    throw new Error(`Unknown column: ${prop}`);
    }
    return column;
    }

    What’s happening:

    Filter values are bound as SQL parameters, but column names cannot be. The sort column and filter columns come from the grid as the prop of a column, so getColumn checks every prop against this map before it reaches SQL. A prop that is not in the map throws an error instead of becoming part of a query.

    Each column also records how it appears in SQL. order_date is a DATE in the table, but the grid filters it as YYYY-MM-DD text, the same string it displays. Sorting still uses the real DATE column.

  4. Map filter conditions to SQL

    When the Filters plugin runs with dataProvider, every fetch receives the current filter state as filters. Each entry describes one column: its prop, a list of conditions (each a name and args), and an operation that combines them.

    Handsontable conditionText columnNumeric column
    eqlower(col) = lower(?)col = ?
    neqcol IS NULL OR lower(col) <> lower(?)col IS NULL OR col <> ?
    containscontains(lower(col), lower(?))-
    not_containscol IS NULL OR NOT contains(lower(col), lower(?))-
    begins_withstarts_with(lower(col), lower(?))-
    ends_withends_with(lower(col), lower(?))-
    gt, gte, lt, lte-col > ?, col >= ?, col < ?, col <= ?
    between-col BETWEEN ? AND ?
    not_between-col < ? OR col > ?
    emptycol IS NULL OR col = ''col IS NULL
    not_emptycol IS NOT NULL AND col <> ''col IS NOT NULL

    The operation takes one of three values:

    operationMeaningSQL
    conjunctionAll conditions match.a AND b
    disjunctionAny condition matches.a OR b
    disjunctionWithExtraConditionAny of the first conditions matches, and the last one also matches.(a OR b) AND c

    Put the mapping in src/filterToSql.ts:

    TypeScript
    import type { DataProviderQueryParameters } from 'handsontable/plugins/dataProvider';
    import { getColumn, type ColumnDef } from './schema';
    type FilterColumn = NonNullable<DataProviderQueryParameters['filters']>[number];
    type FilterCondition = FilterColumn['conditions'][number];
    interface SqlFragment {
    sql: string;
    params: unknown[];
    }
    // Filter values arrive from the UI as strings. Numeric columns need numbers.
    function toNumber(value: unknown): number | null {
    const number = Number(value);
    return value === '' || value === null || Number.isNaN(number) ? null : number;
    }
    // One Handsontable condition -> one SQL predicate with bound parameters.
    // Text comparisons are case-insensitive, matching Handsontable's own conditions.
    function conditionToSql(column: ColumnDef, condition: FilterCondition): SqlFragment | null {
    const { name, args } = condition;
    const e = column.expr;
    if (!name || name === 'none') {
    return null;
    }
    if (name === 'empty') {
    return { sql: column.kind === 'numeric' ? `${e} IS NULL` : `(${e} IS NULL OR ${e} = '')`, params: [] };
    }
    if (name === 'not_empty') {
    return { sql: column.kind === 'numeric' ? `${e} IS NOT NULL` : `(${e} IS NOT NULL AND ${e} <> '')`, params: [] };
    }
    if (column.kind === 'numeric') {
    const values = args.map(toNumber);
    const [a, b] = values;
    if (values.some((value) => value === null)) {
    console.warn(`[dataProvider] Ignoring "${name}": not a number`, args);
    return null;
    }
    switch (name) {
    case 'eq': return { sql: `${e} = ?`, params: [a] };
    case 'neq': return { sql: `(${e} IS NULL OR ${e} <> ?)`, params: [a] };
    case 'gt': return { sql: `${e} > ?`, params: [a] };
    case 'gte': return { sql: `${e} >= ?`, params: [a] };
    case 'lt': return { sql: `${e} < ?`, params: [a] };
    case 'lte': return { sql: `${e} <= ?`, params: [a] };
    // Handsontable accepts the two bounds in either order.
    case 'between':
    return { sql: `${e} BETWEEN ? AND ?`, params: [Math.min(a!, b!), Math.max(a!, b!)] };
    case 'not_between':
    return { sql: `(${e} < ? OR ${e} > ?)`, params: [Math.min(a!, b!), Math.max(a!, b!)] };
    }
    } else {
    const value = String(args[0] ?? '');
    switch (name) {
    case 'eq': return { sql: `lower(${e}) = lower(?)`, params: [value] };
    case 'neq': return { sql: `(${e} IS NULL OR lower(${e}) <> lower(?))`, params: [value] };
    case 'contains': return { sql: `contains(lower(${e}), lower(?))`, params: [value] };
    case 'not_contains': return { sql: `(${e} IS NULL OR NOT contains(lower(${e}), lower(?)))`, params: [value] };
    case 'begins_with': return { sql: `starts_with(lower(${e}), lower(?))`, params: [value] };
    case 'ends_with': return { sql: `ends_with(lower(${e}), lower(?))`, params: [value] };
    }
    }
    console.warn(`[dataProvider] Unsupported filter condition "${name}" on a ${column.kind} column, skipping`);
    return null;
    }
    function join(fragments: SqlFragment[], operator: 'AND' | 'OR'): SqlFragment | null {
    if (fragments.length === 0) {
    return null;
    }
    return {
    sql: `(${fragments.map((fragment) => fragment.sql).join(` ${operator} `)})`,
    params: fragments.flatMap((fragment) => fragment.params),
    };
    }
    function columnToSql(filter: FilterColumn): SqlFragment | null {
    const column = getColumn(filter.prop);
    const toFragments = (conditions: FilterCondition[]) => conditions
    .map((condition) => conditionToSql(column, condition))
    .filter((fragment): fragment is SqlFragment => fragment !== null);
    switch (filter.operation) {
    case 'disjunction':
    return join(toFragments(filter.conditions), 'OR');
    case 'disjunctionWithExtraCondition': {
    // (a OR b OR ...) AND last: the shape Handsontable's Filters plugin produces.
    // Split the conditions before mapping them, so a skipped last condition can't
    // promote one of the OR conditions to the AND side.
    const either = join(toFragments(filter.conditions.slice(0, -1)), 'OR');
    const extra = join(toFragments(filter.conditions.slice(-1)), 'AND');
    return join([either, extra].filter((fragment): fragment is SqlFragment => fragment !== null), 'AND');
    }
    default:
    return join(toFragments(filter.conditions), 'AND');
    }
    }
    // Builds the WHERE clause for every filtered column. Columns are always ANDed together.
    export function filtersToWhere(filters: DataProviderQueryParameters['filters']): SqlFragment {
    const clause = join(
    (filters ?? [])
    .map(columnToSql)
    .filter((fragment): fragment is SqlFragment => fragment !== null),
    'AND',
    );
    return clause ? { sql: `WHERE ${clause.sql}`, params: clause.params } : { sql: '', params: [] };
    }

    What’s happening:

    • Case-insensitive text matching. Handsontable’s own eq, contains, and begins_with conditions ignore case, so the SQL compares lower() values. contains(), starts_with(), and ends_with() take the search text as a plain string, so a % or _ that a user types matches literally and needs no escaping.
    • Numbers arrive as strings. The filter inputs return text, so numeric arguments are converted with Number() before binding. A value that is not a number skips the condition instead of matching nothing.
    • Every operation is handled. A column filter with Or selected arrives as disjunction. The Filters plugin also produces disjunctionWithExtraCondition, so a mapping that treats every non-disjunction value as AND returns the wrong rows.
    • Unsupported conditions are skipped. A condition with no mapping logs a warning and is left out, so an unmapped filter never breaks a fetch.
  5. Write the dataProvider functions

    Create src/ordersProvider.ts. It builds each query from the schema.ts and filterToSql.ts helpers and exports the four functions the grid needs:

    TypeScript
    import type {
    DataProviderFetchOptions,
    DataProviderFetchResult,
    DataProviderQueryParameters,
    RowsCreatePayload,
    RowUpdatePayload,
    } from 'handsontable/plugins/dataProvider';
    import { exclusive, query } from './duckdb';
    import { filtersToWhere } from './filterToSql';
    import { getColumn, SELECT_LIST } from './schema';
    export interface FetchStats {
    totalRows: number;
    ms: number;
    }
    // The App subscribes to this to show how long each fetch took.
    let reportStats: (stats: FetchStats) => void = () => {};
    export function onFetchStats(listener: (stats: FetchStats) => void) {
    reportStats = listener;
    }
    export async function fetchRows(
    { page, pageSize, sort, filters }: DataProviderQueryParameters,
    { signal }: DataProviderFetchOptions,
    ): Promise<DataProviderFetchResult> {
    const where = filtersToWhere(filters);
    const direction = sort?.order === 'desc' ? 'DESC' : 'ASC';
    // LIMIT/OFFSET needs a total order or pages can repeat or skip rows. With no sort,
    // newest rows come first; with a sort, id breaks ties (a region has ~250k rows).
    const orderBy = sort
    ? `${getColumn(sort.prop).sortExpr} ${direction}, id ${direction}`
    : 'id DESC';
    const limit = Math.max(1, Math.trunc(pageSize));
    const offset = Math.max(0, Math.trunc(page) - 1) * limit;
    const { rows, totalRows, ms } = await exclusive(async ({ conn }) => {
    const started = performance.now();
    const pageRows = await query(
    conn,
    `SELECT ${SELECT_LIST} FROM orders ${where.sql} ORDER BY ${orderBy} LIMIT ${limit} OFFSET ${offset}`,
    where.params,
    );
    const [{ total }] = await query<{ total: number }>(
    conn,
    `SELECT count(*) AS total FROM orders ${where.sql}`,
    where.params,
    );
    return { rows: pageRows, totalRows: total, ms: performance.now() - started };
    });
    // DuckDB-Wasm can't cancel a prepared query, so a superseded result is simply dropped.
    if (signal.aborted) {
    return { rows: [], totalRows: 0 };
    }
    reportStats({ totalRows, ms });
    return { rows, totalRows };
    }
    // DuckDB-Wasm binds JS numbers as DOUBLE, so integer-only arguments need an explicit cast.
    export function onRowsCreate({ rowsAmount }: RowsCreatePayload): Promise<unknown[]> {
    return exclusive(({ conn }) => query(
    conn,
    `INSERT INTO orders
    SELECT id, 'ORD-' || lpad(id::VARCHAR, 7, '0'), 'New customer', 'North America',
    'Laptop', 1, 0, 'Pending', current_date
    FROM (SELECT nextval('orders_id_seq') AS id FROM range(CAST(? AS BIGINT)))
    RETURNING id`,
    [rowsAmount],
    ));
    }
    function toSqlValue(prop: string, value: unknown): unknown {
    const column = getColumn(prop);
    if (!column.editable) {
    throw new Error(`${prop} is read-only`);
    }
    if (column.kind === 'numeric') {
    const number = Number(value);
    if (value === '' || value === null || Number.isNaN(number)) {
    throw new Error(`${prop} must be a number`);
    }
    return number;
    }
    return value === null || value === undefined ? null : String(value);
    }
    // All edits from one user action (a paste can touch hundreds of rows) commit in one transaction.
    export function onRowsUpdate(rows: RowUpdatePayload[]): Promise<void> {
    return exclusive(async ({ conn }) => {
    await conn.query('BEGIN TRANSACTION');
    try {
    for (const { id, changes } of rows) {
    const props = Object.keys(changes);
    const assignments = props.map((prop) => (getColumn(prop).kind === 'date'
    ? `${prop} = CAST(? AS DATE)`
    : `${prop} = ?`));
    const values = props.map((prop) => toSqlValue(prop, changes[prop]));
    if (props.length > 0) {
    await query(conn, `UPDATE orders SET ${assignments.join(', ')} WHERE id = ?`, [...values, Number(id)]);
    }
    }
    await conn.query('COMMIT');
    } catch (error) {
    await conn.query('ROLLBACK');
    throw error;
    }
    });
    }
    export function onRowsRemove(rowIds: unknown[]): Promise<void> {
    const ids = rowIds.map(Number).filter(Number.isInteger);
    return exclusive(async ({ conn }) => {
    if (ids.length > 0) {
    await query(conn, `DELETE FROM orders WHERE id IN (${ids.map(() => '?').join(', ')})`, ids);
    }
    });
    }

    What’s happening:

    • fetchRows always orders the rows completely. LIMIT and OFFSET without a total order can return rows in a different order on each call, so a row can appear on two pages or on none. With no sort, the newest rows come first. With a sort, id breaks ties: a million orders across four regions means about 250,000 rows share each region value.
    • The count uses the same WHERE clause. The second query counts every row that matches the filters, which drives the pagination bar.
    • A superseded fetch is dropped, not canceled. DuckDB-Wasm cannot cancel a prepared statement from an AbortSignal. When the user pages or sorts before a query finishes, fetchRows checks signal.aborted after the query and returns an empty result, which the plugin discards.
    • The grid refetches after every mutation. After onRowsCreate, onRowsUpdate, or onRowsRemove succeeds, the plugin calls fetchRows again, and that refetch is what updates the grid. New rows take the next id from the sequence, so they appear at the top of page 1.
    • One user action, one transaction. A paste can change hundreds of rows at once, and they all arrive in a single onRowsUpdate call. The function commits them together, and rolls all of them back if any value is invalid. The plugin then reverts the cells and shows the error.
    • JavaScript numbers bind as DOUBLE. range() accepts only BIGINT, so onRowsCreate casts its argument. Apply the same cast anywhere a function needs an integer type.
  6. Build the grid component

    Create src/OrdersGrid.tsx:

    TypeScript
    import { forwardRef, memo } from 'react';
    import { HotTable, type HotTableRef } from '@handsontable/react-wrapper';
    import type { GridSettings } from 'handsontable/settings';
    import { registerAllModules } from 'handsontable/registry';
    import 'handsontable/styles/handsontable.min.css';
    import 'handsontable/styles/ht-theme-main.min.css';
    import { fetchRows, onRowsCreate, onRowsRemove, onRowsUpdate } from './ordersProvider';
    registerAllModules();
    // Defined once at module level. The wrapper passes changed props to updateSettings(), and a
    // new dataProvider object makes the plugin refetch. Inline literals would refetch on every
    // parent render, and since each fetch updates the parent's stats, the grid would never settle.
    const DATA_PROVIDER: GridSettings['dataProvider'] = {
    rowId: 'id',
    fetchRows,
    onRowsCreate,
    onRowsUpdate,
    onRowsRemove,
    };
    const COL_HEADERS = ['ID', 'Order', 'Customer', 'Region', 'Product', 'Qty', 'Unit price', 'Status', 'Order date'];
    const COLUMNS: GridSettings['columns'] = [
    { data: 'id', type: 'numeric', readOnly: true },
    { data: 'order_no', type: 'text', readOnly: true, width: 130 },
    { data: 'customer', type: 'text', width: 170 },
    { data: 'region', type: 'text', width: 130 },
    { data: 'product', type: 'text', width: 120 },
    { data: 'quantity', type: 'numeric', width: 80 },
    { data: 'unit_price', type: 'numeric', numericFormat: { style: 'currency', currency: 'USD' }, width: 110 },
    { data: 'status', type: 'text', width: 110 },
    { data: 'order_date', type: 'text', width: 110 },
    ];
    const HIDDEN_COLUMNS = { columns: [0] };
    const PAGINATION = { pageSize: 50 };
    const DROPDOWN_MENU = ['filter_by_condition', 'filter_operators', 'filter_by_condition2', 'filter_action_bar'];
    const CONTEXT_MENU = ['row_above', 'row_below', 'remove_row'];
    // memo keeps parent re-renders (the stats bar updates after every fetch) away from HotTable.
    const OrdersGrid = memo(forwardRef<HotTableRef>((_, ref) => (
    <HotTable
    // licenseKey="your-license-key" // get a key at https://handsontable.com/pricing/
    ref={ref}
    themeName="ht-theme-main"
    dataProvider={DATA_PROVIDER}
    colHeaders={COL_HEADERS}
    columns={COLUMNS}
    hiddenColumns={HIDDEN_COLUMNS}
    // Counts hidden columns too: id (hidden), order_no and customer.
    fixedColumnsStart={3}
    pagination={PAGINATION}
    // Single-column, server-driven sort. multiColumnSorting would disable dataProvider.
    columnSorting={true}
    filters={true}
    dropdownMenu={DROPDOWN_MENU}
    notification={true}
    emptyDataState={true}
    contextMenu={CONTEXT_MENU}
    rowHeaders={true}
    height={620}
    stretchH="all"
    />
    )));
    export default OrdersGrid;

    What’s happening:

    • Every prop is defined once, outside the component. The React wrapper passes changed props to updateSettings(), and a new dataProvider object makes the plugin fetch again. If you write the config inline, every parent render triggers a refetch. In this app, every fetch updates the parent’s status bar, so the grid would refetch forever and never leave its loading state. Module-level constants and memo keep the grid out of that loop.
    • columnSorting, not multiColumnSorting. The dataProvider plugin sends one sort column to fetchRows, and it does not enable when multiColumnSorting is on.
    • No filter by value. In server mode, the Filter by value checklist could only list the values on the loaded page, so dropdownMenu leaves it out. The condition filters from Step 4 run against all million rows.
    • fixedColumnsStart counts hidden columns. The frozen span starts at the hidden id column, so fixedColumnsStart={3} freezes the two visible columns, order_no and customer. With {2}, only order_no would stay in place.
    • notification shows errors as toasts. A failed fetch or a rejected edit, such as an invalid date, appears as a toast with DuckDB’s error message.
  7. Load and export Parquet or CSV

    Replace src/App.tsx:

    TypeScript
    import { useEffect, useRef, useState, type ChangeEvent } from 'react';
    import type { HotTableRef } from '@handsontable/react-wrapper';
    import OrdersGrid from './OrdersGrid';
    import { onFetchStats, type FetchStats } from './ordersProvider';
    import { exportParquet, loadFile, ROW_COUNT, seedOrders } from './seed';
    type Status =
    | { state: 'loading' }
    | { state: 'ready'; seedMs: number }
    | { state: 'error'; message: string };
    const format = new Intl.NumberFormat('en-US');
    // StrictMode runs effects twice in development; seed only once per page load.
    let seeding: Promise<number> | null = null;
    function seedOnce(): Promise<number> {
    seeding ??= (async () => {
    const started = performance.now();
    await seedOrders();
    return performance.now() - started;
    })();
    return seeding;
    }
    export default function App() {
    const gridRef = useRef<HotTableRef>(null);
    const fileInputRef = useRef<HTMLInputElement>(null);
    const [status, setStatus] = useState<Status>({ state: 'loading' });
    const [stats, setStats] = useState<FetchStats | null>(null);
    const [message, setMessage] = useState('');
    useEffect(() => {
    onFetchStats(setStats);
    seedOnce()
    .then((seedMs) => setStatus({ state: 'ready', seedMs }))
    .catch((error: Error) => setStatus({ state: 'error', message: error.message }));
    }, []);
    async function handleFile(event: ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    event.target.value = '';
    if (!file) {
    return;
    }
    setMessage(`Loading ${file.name}…`);
    try {
    const total = await loadFile(file);
    setMessage(`Loaded ${format.format(total)} rows from ${file.name}`);
    await gridRef.current?.hotInstance?.getPlugin('dataProvider').fetchData({ page: 1 });
    } catch (error) {
    setMessage(`Couldn't load ${file.name}: ${(error as Error).message}`);
    }
    }
    async function handleExport() {
    try {
    const url = URL.createObjectURL(await exportParquet());
    const link = Object.assign(document.createElement('a'), { href: url, download: 'orders.parquet' });
    link.click();
    // Revoke after the click is handled, so the browser has started the download.
    setTimeout(() => URL.revokeObjectURL(url), 0);
    } catch (error) {
    setMessage(`Couldn't export: ${(error as Error).message}`);
    }
    }
    return (
    <main>
    <header>
    <h1>{format.format(ROW_COUNT)} rows, no backend</h1>
    <p>
    Handsontable's <code>dataProvider</code> sends every page, sort, filter and edit to
    DuckDB-Wasm running in a Web Worker. The grid only ever holds one page.
    </p>
    </header>
    <section className="toolbar" aria-live="polite">
    {status.state === 'loading' && <span>Generating {format.format(ROW_COUNT)} rows in DuckDB…</span>}
    {status.state === 'error' && <span className="error">DuckDB failed to start: {status.message}</span>}
    {status.state === 'ready' && (
    <>
    <span className="stat">Seeded in <strong>{Math.round(status.seedMs)} ms</strong></span>
    {stats && (
    <>
    <span className="stat">Matching rows <strong>{format.format(stats.totalRows)}</strong></span>
    <span className="stat">Last query <strong>{stats.ms.toFixed(1)} ms</strong></span>
    </>
    )}
    <span className="spacer" />
    {/* A native button stays in the tab order and opens the hidden file input. */}
    <button type="button" className="button" onClick={() => fileInputRef.current?.click()}>
    Load Parquet or CSV
    </button>
    <input ref={fileInputRef} type="file" accept=".parquet,.csv" onChange={handleFile} hidden />
    <button type="button" className="button" onClick={handleExport}>Export Parquet</button>
    </>
    )}
    </section>
    {message && <p className="message">{message}</p>}
    {status.state === 'ready' && <OrdersGrid ref={gridRef} />}
    </main>
    );
    }

    What’s happening:

    • The grid mounts after the data exists. App seeds the table first, then renders OrdersGrid, so the first fetch never queries a missing table. seedOnce keeps React’s development double mount from generating the table twice.
    • A user’s file stays in the browser. loadFile from Step 2 registers the file with DuckDB under a fixed internal name, so the user’s file name never appears in SQL. It reads Parquet with read_parquet and CSV with read_csv_auto, and casts every column to the demo schema. The rows land in a temporary table first, and loadFile rejects the file if any id is missing or repeated, because edits and deletes match rows by id: two rows with the same id would change or disappear together. A file that fails a check, or lacks a column, shows the error, and the current table stays as it was. App then calls fetchData({ page: 1 }) on the plugin to show the new data.
    • Export writes the current table. exportParquet runs COPY orders TO 'orders.parquet' inside DuckDB’s virtual file system and hands the bytes to the browser as a download, edits included.
    • The file picker is a real button. A hidden <input type="file"> is not in the tab order, so a native <button> opens it through a ref. Keyboard users can reach and press Load Parquet or CSV like any other button.
    • The status bar reads each fetch. onFetchStats reports the matching row count and the query time. Updating App state is safe here because OrdersGrid does not re-render with it.

    Run npm run dev and open the app. Sort a column, filter by region, or page to the end to see the query time for each change.

How it works — complete flow

  1. App starts DuckDB in a worker and runs the seed SQL. When the table exists, it renders OrdersGrid.
  2. Handsontable calls fetchRows with { page: 1, pageSize: 50 }. DuckDB returns the 50 newest orders and the total count, and the grid renders the page.
  3. The user clicks the Qty header. Handsontable calls fetchRows with sort: { prop: 'quantity', order: 'asc' }, and DuckDB runs ORDER BY quantity ASC, id ASC.
  4. The user filters Region to contain emea. filtersToWhere builds WHERE (contains(lower(region), lower(?))) and binds 'emea'. The status bar shows the new match count.
  5. The user edits a cell. Handsontable calls onRowsUpdate with [{ id, changes }]. The update commits in a transaction, and the plugin refetches the page.
  6. The user inserts a row from the context menu. onRowsCreate takes the next id from the sequence, and the refetch shows the new row at the top of page 1.
  7. The user deletes rows. onRowsRemove receives their id values and runs one DELETE ... WHERE id IN (...).

Known limitations

  • The data lives in the tab. DuckDB keeps the table in the worker’s memory, and a reload regenerates it. Use Export Parquet to keep edits. For automatic persistence, DuckDB-Wasm can also store a database file in the browser’s Origin Private File System (OPFS).
  • The engine is a large download. The EH .wasm file is about 34 MB, or about 8 MB compressed. Serve it with compression and long-term caching, and show a loading state while it starts.
  • Memory grows with the data. The million generated rows fit comfortably in a desktop browser. Larger files are limited by the memory the browser gives the tab.
  • Uploaded files must match the schema. loadFile expects the demo columns. To support any file, read its columns with DESCRIBE and build the grid’s columns and the schema.ts map from them.
  • A development warning in React StrictMode. StrictMode mounts the grid, unmounts it, and mounts it again. With Handsontable 18.1.2, the discarded first instance logs Data fetch failed: TypeError: Cannot read properties of undefined (reading 'runHooks'). The second instance loads normally, and production builds do not double-mount.

What you learned

  • How to run DuckDB-Wasm in a Web Worker and serve its files with Vite, with no CDN and no cross-origin isolation headers.
  • How dataProvider drives pagination, sorting, filtering, and CRUD through four functions, whatever the data source.
  • Why LIMIT and OFFSET need a tiebreaker column to return stable pages.
  • How to map all three filter operations, including disjunctionWithExtraCondition, to SQL.
  • Why grid props must stay referentially stable when a parent re-renders on every fetch.
  • How to load a user’s Parquet or CSV file in the browser and export the table back to Parquet.

Next steps