@updog/data-editor
v0.1.22
Published
Client-side CSV importer and spreadsheet editor SDK for React. Import CSV, Excel, JSON, TSV, and XML, match columns, validate, and edit 1M+ rows entirely in the browser.
Maintainers
Readme
@updog/data-editor
Client-side CSV importer and spreadsheet editor SDK for React. Your users import files, match columns to your schema, fix errors, and submit clean data. Edits happen inline, in the browser, at 1M+ rows.
What is @updog/data-editor
@updog/data-editor is a commercial React SDK that embeds a complete data import wizard and spreadsheet editor into your application.
- Import CSV, Excel (XLSX), TSV, JSON, and XML — column auto-matching, value mapping, multi-source merging, validation
- Edit 1M+ rows with undo/redo, find & replace, copy/paste, fill handle, sorting, filtering, and bulk transforms
- Submit clean data through a single onComplete callback, classified into insert / update / delete
Everything runs client-side. Files are parsed, validated, and edited in the user's browser — no upload server, no data residency, no DPA required.
Not on React? The same SDK ships as a framework-agnostic Web Component —
@updog/data-editor-wc— for Vue, Angular, Svelte, or vanilla JS.
Requirements
- React 18.x or 19.x
- An API key — this is a commercial SDK. Sign up at updog.tech to get one.
Installation
npm install @updog/data-editorQuick Start
import { useState } from "react";
import { DataEditor } from "@updog/data-editor";
import "@updog/data-editor/styles.css";
import type { DataEditorColumn } from "@updog/data-editor";
type Employee = {
id: string;
name: string;
email: string;
role: string;
};
const columns: DataEditorColumn[] = [
{
id: "name",
title: "Full Name",
size: 200,
validators: [{ type: "required", message: "Name is required" }],
},
{
id: "email",
title: "Email",
size: 250,
validators: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Invalid email" },
],
},
{
id: "role",
title: "Role",
editor: { type: "select", options: ["Admin", "Editor", "Viewer"] },
},
];
export function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open Editor</button>
<DataEditor<Employee>
apiKey="your-license-key"
open={open}
onClose={() => setOpen(false)}
columns={columns}
primaryKey="id"
loadData={async (onChunk) => {
const res = await fetch("/api/employees");
onChunk(await res.json());
}}
onComplete={async (result) => {
for (const source of result.sources) {
const inserts = source.rows.filter((r) => r.isNew && !r.isDeleted && r.isValid);
const updates = source.rows.filter((r) => !r.isNew && r.isChanged && !r.isDeleted && r.isValid);
const deletes = source.rows.filter((r) => r.isDeleted && !r.isNew);
await persist(source.sourceId, { inserts, updates, deletes });
}
setOpen(false);
}}
/>
</>
);
}TypeScript
DataEditor accepts a generic type parameter for row data. This gives you type-safe access to primaryKey and the result rows in onComplete.
type Employee = { id: string; name: string; email: string };
<DataEditor<Employee>
primaryKey="id" // autocomplete shows "id" | "name" | "email"
// ...
/>When you omit the generic, rows are typed as Record<string, unknown>.
Inline Mode
Render the editor directly in the DOM without a modal overlay:
<DataEditor
mode="inline"
apiKey="your-license-key"
columns={columns}
primaryKey="id"
loadData={loadData}
onComplete={onComplete}
/>In inline mode, open and onClose don't apply.
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| apiKey | string | Yes | — | Your Updog license key. Validated on each open. |
| columns | DataEditorColumn[] | Yes | — | Column definitions. |
| primaryKey | keyof TRow | Yes | — | Column ID that uniquely identifies each row. |
| mode | "modal" | "inline" | No | "modal" | Rendering mode. |
| open | boolean | Modal only | — | Controlled modal visibility. |
| onClose | () => void | Modal only | — | Called when the user closes the modal (X button or Escape). |
| loadData | (onChunk) => Promise<void> | No | — | Async data loader. Stream rows in chunks, optionally tagged by source. |
| onComplete | (result) => void \| Promise<void> | No | — | Called on submit. See Submission Result. |
| variant | "editor" | "uploader" | No | "editor" | Initial view. "uploader" opens the import wizard first. |
| translations | DataEditorTranslations | No | — | Partial i18n overrides. |
| locale | string | No | "en" | BCP 47 locale tag. |
| rtl | boolean | No | false | Right-to-left layout. |
| readonly | boolean | No | false | Hide all editing UI. |
| enableDeleteRow | "all" | "new" | false | No | false | Row deletion policy. |
| enableAddRow | boolean | No | true | Show the "Add row" button. |
| enableCreateColumn | boolean | No | true | Allow creating columns for unmatched CSV headers during import. |
| importFormats | DataEditorFormat[] | No | all | Allowed import formats. [] disables import. |
| exportFormats | DataEditorFormat[] | No | all | Allowed export formats. [] disables export. |
| remoteSources | RemoteSource[] | No | — | Custom import buttons (Google Sheets, S3, etc.) rendered on the upload step. |
| rowHeight | number | No | 34 | Row height in pixels. |
| headerHeight | number | No | 36 | Header height in pixels. |
| server | DataEditorServer<TRow> | No | — | Server-delegated mode: SDK renders, your backend handles queries and mutations. |
| chat | DataEditorChat<TRow> | No | — | Bring-your-own AI chat panel. |
| onColumnMatch | (headers, columns) => ... | No | — | Override import column matching. |
| onValueMatch | (valuesToMatch) => ... | No | — | Override import value matching for select columns. |
| synonyms | Record<string, string[]> | No | — | Extra synonyms for column auto-matching. |
| sampleData | Record<string, unknown>[] | No | — | Rows used in the "Download Example" file. |
| localStorage | false | { licenseGrant?: boolean } | No | { licenseGrant: true } | What the SDK caches in localStorage. |
| onError | (error: UpdogError) => void | No | — | Called on internal errors. Use for logging or Sentry. |
| className | string | No | — | CSS class on the wrapper element. |
Column Configuration
import type { DataEditorColumn } from "@updog/data-editor";
const columns: DataEditorColumn[] = [
{
id: "email",
title: "Email",
size: 260,
// Declarative validators. See "Built-in Validators" for the full list.
validators: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email" },
],
// Flag duplicates in this column as errors.
unique: true,
// Revalidate these columns when this one changes.
dependentFields: ["confirmEmail"],
// Cell editor. Default is "text".
editor: { type: "select", options: ["US", "UK", "DE"] },
// Display-only formatting. Does not mutate stored data.
formatter: (value) => value.toLowerCase(),
// Runs when rows are uploaded to the editor. Mutates the stored value.
transformer: (value) => String(value).trim(),
// Sidebar filter control.
filter: { type: "select" },
// Lock cells in this column. `"default"` locks only default-source rows.
locked: "default",
},
];Cell editors: { type: "text" } (default), { type: "date", minDate?, maxDate? }, { type: "select", options: string[] }, { type: "number", decimalPlaces?, decimalSeparator?, thousandsSeparator?, allowChars? }.
Column filters: { type: "select", label?, placeholder?, options?, multiple? }, { type: "number-range", label? }, { type: "date-range", label? }.
Built-in Validators
Validators are declarative objects passed in the validators array on each column. The SDK ships a TS interpreter for the client; Updog Scale ships a matching Go interpreter for server mode. Both runtimes are pinned to the same fixture set, so a column definition produces identical pass/fail results regardless of where it runs.
| type | Fields | Behavior |
|---|---|---|
| "required" | message? | Value must be non-empty. |
| "email" | message? | Value must look like an email address. |
| "numeric" | message? | Value must parse as a number. Commas are stripped before parsing. |
| "regex" | pattern, flags?, message? | Value must match the regular expression. Empty values are skipped. |
| "range" | min?, max?, message? | Value must be numerically within [min, max]. Either bound is optional. |
| "oneOf" | values: string[], message? | Value must be one of the listed strings. |
| "date" | format?: "YYYY-MM-DD" \| "DD/MM/YYYY", message? | Value must parse as a date in the given format. When format is omitted, either format is accepted. |
const columns: DataEditorColumn[] = [
{ id: "name", title: "Name", validators: [{ type: "required", message: "Required" }] },
{
id: "email",
title: "Email",
validators: [
{ type: "required", message: "Required" },
{ type: "email", message: "Invalid email" },
],
},
{ id: "salary", title: "Salary", validators: [{ type: "numeric", message: "Must be a number" }] },
{
id: "status",
title: "Status",
validators: [{ type: "oneOf", values: ["active", "inactive"], message: "Invalid status" }],
},
{
id: "startDate",
title: "Start",
validators: [{ type: "date", format: "YYYY-MM-DD", message: "Use YYYY-MM-DD" }],
editor: { type: "date" },
dependentFields: ["endDate"],
},
];A ValidationError with level: "error" flags the cell in the grid but does not block submission — invalid rows are delivered to onComplete tagged via isValid: false.
Custom validators
For cross-field checks or anything not covered by the built-ins, wrap a CellValidator function in { type: "function", fn }. Function validators are client-mode only — they're dropped (with a one-time warning) when the SDK serializes the schema for server mode. Use dependentFields on the column to trigger re-validation when another column changes.
import type { CellValidator, DataEditorColumn } from "@updog/data-editor";
const endAfterStart: CellValidator = (value, row) => {
if (!value || !row.startDate) return null;
if (String(value) < String(row.startDate)) {
return { level: "error", message: "End must be on or after start" };
}
return null;
};
const columns: DataEditorColumn[] = [
{
id: "endDate",
title: "End",
editor: { type: "date" },
validators: [
{ type: "date", message: "Invalid date" },
{ type: "function", fn: endAfterStart },
],
},
];The only level is "error". Return null to indicate the value is valid.
Data Loading
loadData is called once when the editor opens. Call onChunk one or more times to stream rows. The grid renders each chunk without blocking.
// Single source
loadData={async (onChunk) => {
for (let page = 0; page < totalPages; page++) {
const rows = await fetch(`/api/employees?page=${page}`).then((r) => r.json());
onChunk(rows);
}
}}
// Multiple sources — each chunk tagged with a source
loadData={async (onChunk) => {
onChunk(await fetchSalesforce(), { source: "Salesforce", done: true });
onChunk(await fetchHubSpot(), { source: "HubSpot", deletable: true, done: true });
}}ChunkSourceOptions: { source: string; id?: string; deletable?: boolean; done?: boolean }. Sources are auto-registered on first encounter. Chunks without options go to "Existing Data". When loadData resolves, any source still loading is finalized automatically.
Submission Result
onComplete(result) fires when the user submits. May return a promise.
type DataEditorResult<TRow> = {
sources: {
sourceId: string;
sourceName: string;
rows: {
row: TRow;
isNew: boolean; // imported or manually added this session
isChanged: boolean; // cell values differ from origin
isDeleted: boolean; // marked for deletion
isValid: boolean; // passes all validators
}[];
}[];
counts: { new: number; changed: number; deleted: number; invalid: number };
};Pristine backend rows (unchanged, not deleted, not new) are omitted from sources[].rows — they're no-ops. The flags are orthogonal: a row can be new, changed, and deleted at once. You own the routing rules:
onComplete={async (result) => {
for (const source of result.sources) {
const inserts = source.rows.filter((r) => r.isNew && !r.isDeleted && r.isValid);
const updates = source.rows.filter((r) => !r.isNew && r.isChanged && !r.isDeleted && r.isValid);
const deletes = source.rows.filter((r) => r.isDeleted && !r.isNew);
await persist(source.sourceId, { inserts, updates, deletes });
}
}}If you never tagged sources via loadData, you'll get one entry with sourceId: "backend". result.counts is an aggregate for summary UI — route with the per-row flags, not counts.
Import Configuration
The import wizard guides users through uploading a file, mapping columns to your schema, and resolving value mismatches.
Formats
Supported: "csv", "tsv", "xlsx", "json", "xml".
<DataEditor
importFormats={["csv", "xlsx"]} // only CSV and XLSX
exportFormats={[]} // disable export entirely
/>Override column matching
onColumnMatch={async (headers, columns) => {
const mappings = await myMatchingService.match(headers, columns);
return mappings; // { csvHeader: columnId | null }
}}Return a map of { csvHeader: columnId | null }. Entries set to null or omitted fall back to built-in matching.
Override value matching
For select columns, override imported-value → option mapping:
onValueMatch={async (valuesToMatch) => {
// valuesToMatch = { country: { importedValues: ["espana", "fr"], options: ["Spain", "France"] } }
return {
country: { espana: "Spain", fr: "France" },
};
}}Values set to null skip auto-matching. Unmapped values fall back to built-in fuzzy matching.
Synonyms
Extra aliases for the built-in column matcher:
synonyms={{
productSku: ["sku", "article_no", "item_code"],
firstName: ["first", "given_name", "fname"],
}}Remote sources
Render custom import buttons for third-party sources. You own auth and picker logic; the SDK renders the button and processes the result.
const googleSheets: RemoteSource = {
id: "google-sheets",
label: "Google Sheets",
icon: "<svg>...</svg>",
fetch: async () => {
const data = await myGoogleSheetsLib.pick();
return data.rows; // Record<string, unknown>[] — or return a File
},
};
<DataEditor remoteSources={[googleSheets]} {...rest} />Return a File to parse via the standard CSV/XLSX/JSON/XML pipeline, or return structured records to skip parsing.
Error Handling
Log SDK-internal errors through onError. The SDK recovers gracefully — use this for monitoring, not recovery.
onError={(error) => {
Sentry.captureException(error.originalError ?? error, {
tags: { code: error.code, source: error.source },
});
}}Error codes: PARSE_ERROR, RENDER_ERROR, TRANSFORM_ERROR, VALIDATION_ERROR, WORKER_ERROR, COMMAND_ERROR, OPERATION_ERROR.
Non-React Usage
For Angular, Vue, Svelte, or vanilla JS use the Web Component build:
npm install @updog/data-editor-wcFAQ
Does my user's data leave the browser? No. Parsing, validation, mapping, and editing all run client-side. Your code controls when (and whether) data is submitted to your own backend.
Is there a row limit? No hard cap. The grid is engineered for 1M+ rows using canvas rendering and virtual scrolling.
Are there per-import or per-row fees? No. Pricing is flat per production domain. See updog.tech/#pricing.
Can I white-label? Yes, on every plan including the free tier. Strip all Updog branding via CSS theming.
Which file formats are supported? CSV, Excel (XLSX), TSV, JSON, and XML on both import and export.
License
Commercial — see LICENSE for the full terms of the Updog SDK Commercial License v1.0. Contact [email protected] for enterprise licensing. Third-party dependencies are listed in THIRD_PARTY_NOTICES.txt.
