Skip to content

Columns

The columns prop defines every column in the grid. Each entry is a DataEditorColumn object.

<DataEditor columns={columns} ... />
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";
};
Typestring
RequiredYes

Unique column identifier. Must match the keys in your row data.

Typestring
RequiredYes

Column header text shown to the user.

TypeCellEditor
Default{ type: "text" }

Controls how the cell is edited. Four types available:

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 };

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") }

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 }

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 input with locale-aware formatting.

{
type: "number",
decimalPlaces: 2,
decimalSeparator: ".",
thousandsSeparator: ",",
allowChars: "%-",
}
FieldTypeDescription
decimalPlacesnumberMaximum decimal digits. Unrestricted when omitted.
decimalSeparatorstringDecimal point character. Defaults to browser locale.
thousandsSeparatorstringThousands grouping character. Defaults to browser locale.
allowCharsstringExtra characters to allow beyond digits, decimal separator, and minus sign.
TypeValidatorRule[]

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.

type ValidatorRule =
| BuiltInValidator
| { type: "function"; fn: CellValidator }
| AsyncFunctionValidator;
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.

Each rule is an object literal. The optional message overrides the default localized error text.

Rejects empty, null, or undefined values.

{ type: "required", message: "Name is required" }

Rejects non-numeric values. Handles comma and dot separators.

{ type: "numeric", message: "Must be a number" }

Validates email format.

{ type: "email", message: "Invalid email address" }

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" }

Restricts to a set of allowed values.

{ type: "oneOf", values: ["Active", "Inactive"], message: "Must be Active or Inactive" }

Validates against a regular expression. pattern is a string compiled at runtime; flags is optional.

{ type: "regex", pattern: "^\\+[\\d\\s]+$", message: "Invalid phone number" }

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" }

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.

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,
}

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"],
}

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 validators array. 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 fn once 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 inside fn, and stream results back via onChunk as they arrive. Do retries inside fn too — 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 signal fires 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.

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.

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.

Typestring[]

Column IDs to revalidate when this column changes.

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.

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 }
TypeColumnFilter

Adds a filter control for this column in the sidebar Filters panel.

type ColumnFilter =
| { type: "select"; label?: string; placeholder?: string; options?: string[]; multiple?: boolean }
| { type: "number-range"; label?: string }
| { type: "date-range"; label?: string };

Dropdown to pick one or more values.

{ type: "select", label: "Status", placeholder: "All statuses", options: ["Active", "Inactive"], multiple: true }
FieldTypeDescription
labelstringLabel above the filter.
placeholderstringPlaceholder when nothing selected.
optionsstring[]Fixed options. When omitted, derived from column values.
multiplebooleanAllow multiple selections.

Two inputs for min and max.

{ type: "number-range", label: "Salary Range" }

Two date pickers for start and end date.

{ type: "date-range", label: "Date Range" }
Typeboolean
Defaulttrue

Whether this column can be pinned to the left (right in RTL) via the header context menu.

Typenumber
Default150

Column width in pixels.

Typeboolean | "all" | "default"

Controls whether cells in this column are locked.

  • true or "all" — locked for every row.
  • "default" — locked only for default-source rows. Rows added manually, duplicated, or imported remain editable.
  • false or undefined — not locked.

A column locked via configuration cannot be unlocked from the UI.

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,
},
];