Columns
The columns prop defines every column in the grid. Each entry is a DataEditorColumn object.
<DataEditor columns={columns} ... />Column Shape
Section titled “Column Shape”type DataEditorColumn = { id: string; title: string; editor?: CellEditor; validators?: ValidatorRule[]; dependentFields?: string[]; formatter?: (value: string) => string; transformer?: (value: unknown) => unknown; filter?: ColumnFilter; pinnable?: boolean; size?: number; locked?: boolean | "all" | "default";};| Type | string |
| Required | Yes |
Unique column identifier. Must match the keys in your row data.
| Type | string |
| Required | Yes |
Column header text shown to the user.
editor
Section titled “editor”| Type | CellEditor |
| Default | { type: "text" } |
Controls how the cell is edited. Four types available:
CellEditor
Section titled “CellEditor”type CellEditor = | { type: "text" } | { type: "date"; minDate?: Date; maxDate?: Date } | { type: "select"; options: string[]; enableCustomValue?: boolean } | { type: "multiselect"; options: string[]; enableCustomValue?: boolean; delimiter?: string } | { type: "number"; decimalPlaces?: number; decimalSeparator?: string; thousandsSeparator?: string; allowChars?: string };Text (default)
Section titled “Text (default)”Plain text input.
{ type: "text" }Date picker with optional min/max bounds.
{ type: "date", minDate: new Date("2020-01-01"), maxDate: new Date("2030-12-31") }Select
Section titled “Select”Dropdown with a fixed list of options. Each string is both the stored value and the display label.
{ type: "select", options: ["Admin", "Editor", "Viewer"] }By default (enableCustomValue true), values outside options are accepted in the editor: users can add new options inline (in the value-matching step and the grid), created options persist on the column, and off-list values appear in the column filter. On import a select value is kept only if it is mapped, so to keep an off-list value the user maps it to an existing option or explicitly creates a new option for it; values left unmatched are dropped. Off-list values are never added as options implicitly. For columns with many off-list values, the value-matching drawer offers a one-click action that creates an option for every unmatched value — each option is an exact copy of the imported value. The SDK does not validate membership; add a oneOf validator if you want to constrain the value. Set enableCustomValue: false for a strict closed enum: no inline option creation, and values can only map to an existing option.
{ type: "select", options: ["Admin", "Editor", "Viewer"], enableCustomValue: false }Multiselect
Section titled “Multiselect”Dropdown where users pick zero or more options. The stored cell value is a string[], not a string.
{ type: "multiselect", options: ["red", "green", "blue"] }The grid paints the values as joined text (red, blue), and exports join the array with the column delimiter (default ", "). Filtering and search match per element: a cell ["red", "blue"] matches a red value filter and a blue text search.
enableCustomValue works as it does for select (defaults to true): users can create options inline, and off-list values are kept only when mapped. A oneOf validator is applied per element, so every value in the array must be an allowed option for the row to pass.
On import, a raw cell holding several values is split into tokens. The delimiter is auto-detected among ,, ;, |, newline, and tab by matching tokens against your options. Set delimiter explicitly when the file’s vocabulary does not resemble your options (for example file values red, green against option codes R/G/B), where auto-detection cannot infer the separator. A cell that is itself a whole option containing the delimiter (option "Smith, Jr") is never split.
{ type: "multiselect", options: ["red", "green", "blue"], delimiter: ";" }Number
Section titled “Number”Number input with locale-aware formatting.
{ type: "number", decimalPlaces: 2, decimalSeparator: ".", thousandsSeparator: ",", allowChars: "%-",}| Field | Type | Description |
|---|---|---|
decimalPlaces | number | Maximum decimal digits. Unrestricted when omitted. |
decimalSeparator | string | Decimal point character. Defaults to browser locale. |
thousandsSeparator | string | Thousands grouping character. Defaults to browser locale. |
allowChars | string | Extra characters to allow beyond digits, decimal separator, and minus sign. |
validators
Section titled “validators”| Type | ValidatorRule[] |
One or more validators run on every edit. Each entry is a tagged object describing a rule. Built-in rules cover the common cases; the function rule is the escape hatch for anything custom.
ValidatorRule
Section titled “ValidatorRule”type ValidatorRule = | BuiltInValidator | { type: "function"; fn: CellValidator } | AsyncFunctionValidator;ValidationError
Section titled “ValidationError”type ValidationError = { level: "error"; message: string;};A ValidationError with level: "error" flags the cell in the grid but does not block submission — invalid rows are delivered to onComplete alongside valid ones, tagged via the isValid flag.
Built-in validators
Section titled “Built-in validators”Each rule is an object literal. The optional message overrides the default localized error text.
{ type: "required" }
Section titled “{ type: "required" }”Rejects empty, null, or undefined values.
{ type: "required", message: "Name is required" }{ type: "numeric" }
Section titled “{ type: "numeric" }”Rejects non-numeric values. Handles comma and dot separators.
{ type: "numeric", message: "Must be a number" }{ type: "email" }
Section titled “{ type: "email" }”Validates email format.
{ type: "email", message: "Invalid email address" }{ type: "date" }
Section titled “{ type: "date" }”Validates YYYY-MM-DD or DD/MM/YYYY formats. Pass format to require a specific one.
{ type: "date", format: "YYYY-MM-DD", message: "Invalid date" }{ type: "oneOf" }
Section titled “{ type: "oneOf" }”Restricts to a set of allowed values.
{ type: "oneOf", values: ["Active", "Inactive"], message: "Must be Active or Inactive" }{ type: "regex" }
Section titled “{ type: "regex" }”Validates against a regular expression. pattern is a string compiled at runtime; flags is optional.
{ type: "regex", pattern: "^\\+[\\d\\s]+$", message: "Invalid phone number" }{ type: "range" }
Section titled “{ type: "range" }”Restricts a numeric value to a min/max range. Both bounds are optional.
{ type: "range", min: 0, max: 1_000_000, message: "Must be between 0 and 1,000,000" }{ type: "unique" }
Section titled “{ type: "unique" }”Flags duplicate values in this column as errors. Unlike other rules, uniqueness is relational — the value is checked against every other row in the column, not just the cell itself.
{ type: "unique", message: "This email is already used in another row" }When message is omitted, the error text is localized via the translations prop (dataEditor.validation.valueMustBeUnique).
Execution order is guaranteed, not positional. The unique check always runs last, after every other validator on the column passes — regardless of where { type: "unique" } appears in the validators array. A cell holding an invalid value reports that error, never “must be unique”.
The rule also accepts an optional fn for checking values against your backend — see Remote (async) validation.
Custom validation
Section titled “Custom validation”When a built-in doesn’t fit, drop down to { type: "function" }. The fn receives the cell value and the full row; return a ValidationError to flag a problem, or null if valid.
type CellValidator = (value: unknown, row: DataEditorRow) => ValidationError | null;
{ type: "function", fn: (value, row) => row.country === "US" && !/^\d{5}$/.test(String(value)) ? { level: "error", message: "US ZIP must be 5 digits" } : null,}Cross-field validation
Section titled “Cross-field validation”Use dependentFields together with a function rule to revalidate this column when another column changes:
{ id: "endDate", title: "End Date", editor: { type: "date" }, validators: [{ type: "function", fn: (value, row) => new Date(String(value)) <= new Date(String(row.startDate)) ? { level: "error", message: "End date must be after start date" } : null, }], dependentFields: ["startDate"],}Remote (async) validation
Section titled “Remote (async) validation”Two surfaces check values against your backend: unique.fn for “does this value already exist in your system”, and { type: "asyncFunction" } for any other remote check. Both are client-mode only and share the same guarantees:
- Async runs after all sync validators, regardless of position in the
validatorsarray. A cell that fails a sync validator, is a duplicate inside the file, or is empty (null/"") is never sent remotely — the user fixes the format first, then learns the value is taken. - Batching is yours. The SDK calls your
fnonce per column per operation with everything affected: one edited cell → one call with one cell; a 100k-row paste → one call with 100k cells. Split and parallelize against your backend insidefn, and stream results back viaonChunkas they arrive. Do retries insidefntoo — if it rejects, the remaining cells are marked unverified and the SDK will not re-ask. - Staleness is the SDK’s problem, not yours. Checks are never cancelled — if the data changes mid-check, outdated verdicts are simply ignored on arrival. The
signalfires only when the results can no longer be used at all (60 seconds without any activity, or the editor closed); honoring it just saves your backend work. - Opening a file triggers a full initial check of all values in async-validated columns — your endpoint will receive the whole file’s distinct values.
unique.fn
Section titled “unique.fn”An optional remote existence check on the unique rule:
{ type: "unique"; message?: string; fn?: ( values: unknown[], onChunk: (existing: unknown[]) => void, signal: AbortSignal, ) => Promise<unknown[] | void>;}Called once per sweep with all distinct candidate values (deduped, filtered against a verdict cache). Report the subset that already exists in your system — return it, or stream it in batches via onChunk (both may be combined; results are unioned).
// Simple client — one shot:{ type: "unique", message: "Email already registered", fn: async (values) => { const res = await fetch("/api/check-emails", { method: "POST", body: JSON.stringify(values) }); return res.json(); // the ones that exist — typically tiny },}
// Advanced client — batches itself, streams results; errors paint progressively:{ type: "unique", fn: async (values, onChunk, signal) => { for (const batch of split(values, 1000)) { if (signal.aborted) return; onChunk(await api.checkTaken(batch)); } },}In-file duplicate detection still runs first and locally; values that are locally unique and sync-clean are then checked remotely. The two failures carry distinct error messages, because they demand different user actions: an in-file duplicate shows dataEditor.validation.valueMustBeUnique (or the rule’s message), while a remote hit shows the localized dataEditor.validation.alreadyExists (“Already exists in your database”). The rule’s message overrides the in-file text only; the remote text is not overridable in v1.
Upsert warning: “exists in your database” is an error only for insert-only columns. If your upload updates existing records (see primaryKey merge behavior), do not set unique.fn on columns whose values legitimately already exist.
{ type: "asyncFunction" }
Section titled “{ type: "asyncFunction" }”A batch remote check with full row context — for anything that isn’t uniqueness:
type AsyncValidatorCell = { /** Value of the validated column for this cell. */ value: unknown; /** The full row — for row-dependent checks (region, currency, ...). */ row: DataEditorRow;};
type AsyncFunctionValidator = { type: "asyncFunction"; fn: ( cells: AsyncValidatorCell[], onChunk: (failures: { index: number; error: ValidationError }[]) => void, signal: AbortSignal, ) => Promise<(ValidationError | null)[] | void>;};Report failures by index into cells — return a full array aligned with the input (null = valid), or stream sparse failures via onChunk (combinable; unioned; chunks may arrive in any order).
// Simple client — no index bookkeeping at all: same-length array back.{ type: "asyncFunction", fn: async (cells) => { return api.checkSkus(cells.map(c => ({ sku: c.value, region: c.row.region }))); },}
// Advanced client — batches itself, streams failures; index = batch offset + position:{ type: "asyncFunction", fn: async (cells, onChunk, signal) => { for (let offset = 0; offset < cells.length; offset += 1000) { if (signal.aborted) return; const batch = cells.slice(offset, offset + 1000); const results = await api.checkSkus(batch.map(c => ({ sku: c.value, region: c.row.region }))); onChunk(results.flatMap((err, i) => (err ? [{ index: offset + i, error: err }] : []))); } },}The check is index-keyed rather than value-keyed because the verdict can depend on the row: SKU "ABC-1" may be valid in an EU row and invalid in a US row, so “ABC-1 failed” alone cannot say which cell it means.
Do not implement uniqueness with asyncFunction — use unique.fn. An asyncFunction-based uniqueness check misses in-file duplicates, never self-clears when the conflicting value leaves the file, and gets no value dedupe or verdict caching.
dependentFields
Section titled “dependentFields”| Type | string[] |
Column IDs to revalidate when this column changes.
formatter
Section titled “formatter”| Type | (value: string) => string |
Format the display value without changing stored data.
{ formatter: (v) => v ? `$${v}` : "" }On a multiselect column the formatter runs per element: it receives one option at a time, never the joined string. The SDK formats each value then joins the results, so an option-to-label formatter ((v) => labels[v]) works unchanged.
transformer
Section titled “transformer”| Type | (value: unknown) => unknown |
Transform a value before it enters the store. Runs when rows are uploaded to the data editor.
{ transformer: (v) => typeof v === "string" ? v.trim() : v }filter
Section titled “filter”| Type | ColumnFilter |
Adds a filter control for this column in the sidebar Filters panel.
ColumnFilter
Section titled “ColumnFilter”type ColumnFilter = | { type: "select"; label?: string; placeholder?: string; options?: string[]; multiple?: boolean } | { type: "number-range"; label?: string } | { type: "date-range"; label?: string };Select Filter
Section titled “Select Filter”Dropdown to pick one or more values.
{ type: "select", label: "Status", placeholder: "All statuses", options: ["Active", "Inactive"], multiple: true }| Field | Type | Description |
|---|---|---|
label | string | Label above the filter. |
placeholder | string | Placeholder when nothing selected. |
options | string[] | Fixed options. When omitted, derived from column values. |
multiple | boolean | Allow multiple selections. |
Number Range Filter
Section titled “Number Range Filter”Two inputs for min and max.
{ type: "number-range", label: "Salary Range" }Date Range Filter
Section titled “Date Range Filter”Two date pickers for start and end date.
{ type: "date-range", label: "Date Range" }pinnable
Section titled “pinnable”| Type | boolean |
| Default | true |
Whether this column can be pinned to the left (right in RTL) via the header context menu.
| Type | number |
| Default | 150 |
Column width in pixels.
locked
Section titled “locked”| Type | boolean | "all" | "default" |
Controls whether cells in this column are locked.
trueor"all"— locked for every row."default"— locked only for default-source rows. Rows added manually, duplicated, or imported remain editable.falseorundefined— not locked.
A column locked via configuration cannot be unlocked from the UI.
Complete Example
Section titled “Complete Example”import type { DataEditorColumn } from "@updog/data-editor";
const columns: DataEditorColumn[] = [ { id: "name", title: "Full Name", size: 200, validators: [{ type: "required", message: "Name is required" }], transformer: (v) => typeof v === "string" ? v.trim() : v, }, { id: "email", title: "Email", size: 250, validators: [ { type: "required", message: "Email is required" }, { type: "email", message: "Invalid email" }, { type: "unique" }, ], }, { id: "salary", title: "Salary", editor: { type: "number", decimalPlaces: 2 }, validators: [{ type: "numeric", message: "Must be a number" }], formatter: (v) => v ? `$${v}` : "", filter: { type: "number-range", label: "Salary" }, }, { id: "role", title: "Role", editor: { type: "select", options: ["Admin", "Editor", "Viewer"] }, validators: [{ type: "oneOf", values: ["Admin", "Editor", "Viewer"], message: "Invalid role" }], filter: { type: "select", label: "Role", multiple: true }, }, { id: "startDate", title: "Start Date", editor: { type: "date" }, filter: { type: "date-range", label: "Start Date" }, }, { id: "endDate", title: "End Date", editor: { type: "date" }, validators: [{ type: "function", fn: (value, row) => new Date(String(value)) <= new Date(String(row.startDate)) ? { level: "error", message: "Must be after start date" } : null, }], dependentFields: ["startDate"], }, { id: "notes", title: "Notes", size: 300, locked: false, pinnable: false, },];