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.
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
| Requirement | Version |
|---|---|
handsontable | 18.1 or later |
@handsontable/react-wrapper | 18.1 or later |
@duckdb/duckdb-wasm | 1.32.0 |
| React + Vite | any current version |
Scaffold a Vite + React + TypeScript app if you do not have one, then add the packages:
npm create vite@latest orders-app -- --template react-tscd orders-appnpm install handsontable @handsontable/react-wrappernpm install --save-exact @duckdb/duckdb-wasm@1.32.0Pin @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/:
| File | Purpose |
|---|---|
duckdb.ts | Starts DuckDB in a Web Worker and runs statements one at a time |
seed.ts | Generates the table, loads a user’s file, and exports Parquet |
schema.ts | The columns that SQL is allowed to reference |
filterToSql.ts | Turns Handsontable filter conditions into a SQL WHERE clause |
ordersProvider.ts | The four dataProvider functions |
OrdersGrid.tsx | The grid component |
App.tsx | Seeds the data, shows the status bar, and renders the grid |
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
?urlimports give you URLs to the worker scripts and.wasmfiles insidenode_modules, so the app needs no CDN at runtime.selectBundlepicks 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.
castBigIntToDoublekeeps BigInt out of the grid.COUNT(*)and sequence values areBIGINTin DuckDB, which Apache Arrow returns as JavaScriptBigInt. The cast turns them into ordinary numbers in one place.exclusiveruns 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.querycloses every prepared statement. A prepared statement holds memory in the worker until you close it, so thefinallyblock closes it whether the query succeeds or fails.
- Vite serves the engine. The
Generate a million rows
Create
src/seed.ts. The first function builds theorderstable. 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 ASSELECT(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_dateFROM 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 ASSELECT 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_dateFROM ${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_idFROM 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 fromhash()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 SEQUENCEstarts theidsequence after the last generated row. New rows take theiridfrom it, which matters in Step 5.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
propof a column, sogetColumnchecks everypropagainst this map before it reaches SQL. Apropthat 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_dateis aDATEin the table, but the grid filters it asYYYY-MM-DDtext, the same string it displays. Sorting still uses the realDATEcolumn.Map filter conditions to SQL
When the Filters plugin runs with
dataProvider, every fetch receives the current filter state asfilters. Each entry describes one column: itsprop, a list ofconditions(each anameandargs), and anoperationthat combines them.Handsontable condition Text column Numeric 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 NULLnot_emptycol IS NOT NULL AND col <> ''col IS NOT NULLThe
operationtakes one of three values:operationMeaning SQL conjunctionAll conditions match. a AND bdisjunctionAny condition matches. a OR bdisjunctionWithExtraConditionAny of the first conditions matches, and the last one also matches. (a OR b) AND cPut 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, andbegins_withconditions ignore case, so the SQL compareslower()values.contains(),starts_with(), andends_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 producesdisjunctionWithExtraCondition, so a mapping that treats every non-disjunctionvalue asANDreturns 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.
- Case-insensitive text matching. Handsontable’s own
Write the dataProvider functions
Create
src/ordersProvider.ts. It builds each query from theschema.tsandfilterToSql.tshelpers 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 ordersSELECT id, 'ORD-' || lpad(id::VARCHAR, 7, '0'), 'New customer', 'North America','Laptop', 1, 0, 'Pending', current_dateFROM (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:
fetchRowsalways orders the rows completely.LIMITandOFFSETwithout 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,idbreaks ties: a million orders across four regions means about 250,000 rows share eachregionvalue.- The count uses the same
WHEREclause. 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,fetchRowscheckssignal.abortedafter the query and returns an empty result, which the plugin discards. - The grid refetches after every mutation. After
onRowsCreate,onRowsUpdate, oronRowsRemovesucceeds, the plugin callsfetchRowsagain, and that refetch is what updates the grid. New rows take the nextidfrom 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
onRowsUpdatecall. 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 onlyBIGINT, soonRowsCreatecasts its argument. Apply the same cast anywhere a function needs an integer type.
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 newdataProviderobject 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 andmemokeep the grid out of that loop. columnSorting, notmultiColumnSorting. ThedataProviderplugin sends one sort column tofetchRows, and it does not enable whenmultiColumnSortingis on.- No filter by value. In server mode, the Filter by value checklist could only list the values on the loaded page, so
dropdownMenuleaves it out. The condition filters from Step 4 run against all million rows. fixedColumnsStartcounts hidden columns. The frozen span starts at the hiddenidcolumn, sofixedColumnsStart={3}freezes the two visible columns,order_noandcustomer. With{2}, onlyorder_nowould stay in place.notificationshows errors as toasts. A failed fetch or a rejected edit, such as an invalid date, appears as a toast with DuckDB’s error message.
- Every prop is defined once, outside the component. The React wrapper passes changed props to
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 toDuckDB-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.
Appseeds the table first, then rendersOrdersGrid, so the first fetch never queries a missing table.seedOncekeeps React’s development double mount from generating the table twice. - A user’s file stays in the browser.
loadFilefrom 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 withread_parquetand CSV withread_csv_auto, and casts every column to the demo schema. The rows land in a temporary table first, andloadFilerejects the file if anyidis missing or repeated, because edits and deletes match rows byid: two rows with the sameidwould 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.Appthen callsfetchData({ page: 1 })on the plugin to show the new data. - Export writes the current table.
exportParquetrunsCOPY 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.
onFetchStatsreports the matching row count and the query time. UpdatingAppstate is safe here becauseOrdersGriddoes not re-render with it.
Run
npm run devand open the app. Sort a column, filter by region, or page to the end to see the query time for each change.- The grid mounts after the data exists.
How it works — complete flow
Appstarts DuckDB in a worker and runs the seed SQL. When the table exists, it rendersOrdersGrid.- Handsontable calls
fetchRowswith{ page: 1, pageSize: 50 }. DuckDB returns the 50 newest orders and the total count, and the grid renders the page. - The user clicks the Qty header. Handsontable calls
fetchRowswithsort: { prop: 'quantity', order: 'asc' }, and DuckDB runsORDER BY quantity ASC, id ASC. - The user filters Region to contain
emea.filtersToWherebuildsWHERE (contains(lower(region), lower(?)))and binds'emea'. The status bar shows the new match count. - The user edits a cell. Handsontable calls
onRowsUpdatewith[{ id, changes }]. The update commits in a transaction, and the plugin refetches the page. - The user inserts a row from the context menu.
onRowsCreatetakes the nextidfrom the sequence, and the refetch shows the new row at the top of page 1. - The user deletes rows.
onRowsRemovereceives theiridvalues and runs oneDELETE ... 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
.wasmfile 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.
loadFileexpects the demo columns. To support any file, read its columns withDESCRIBEand build the grid’scolumnsand theschema.tsmap 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
dataProviderdrives pagination, sorting, filtering, and CRUD through four functions, whatever the data source. - Why
LIMITandOFFSETneed 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
- Server-side data overview — the full
dataProviderAPI, lifecycle hooks, and options that do not work in server-side mode. - Fetching data — the query parameters,
beforeDataProviderFetch, and abort behavior. - CRUD operations — the mutation lifecycle, optimistic updates, and programmatic CRUD.
- Server-side data with Supabase — the same four functions against a hosted Postgres database.
- DuckDB-Wasm documentation — data ingestion, extensions, and OPFS persistence.