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

Type CellEditor
Default { type: "text" }

Controls how the cell is edited.

type CellEditor =
| { type: "text" }
| { type: "date" }
| { type: "time"; hourCycle?: "h12" | "h23" }
| { type: "select"; options: string[]; enableCustomValue?: boolean }
| { type: "multiselect"; options: string[]; enableCustomValue?: boolean; delimiter?: string }
| { type: "number"; decimalSeparator?: string; thousandsSeparator?: string };

Plain text input.

{ type: "text" }

Date picker. The calendar honours the min and max of the column’s { type: "date" } validator, so a range is declared once.

A column with this editor is checked against { type: "date" } even when it declares no validator, so a value the importer could not convert is flagged. Declaring a { type: "date" } rule of your own replaces that implicit check.

{ type: "date" }

Masked input for a time of day. The cell shows the time the way the user’s browser writes it, 2:30 PM in the United States and 14:30 in Germany. The stored value is always HH:MM, HH:MM:SS or HH:MM:SS.fff, so a time sorts and compares by value.

Opening the cell puts a mask under the cursor. It walks the person through the slots, writes the separators in, and turns down a digit that would make a time nobody has: an hour past 23, a minute past 59. A digit that can only stand alone takes a leading zero as it is typed, so 189 becomes 18:09. There is no picker. On a twelve-hour column the morning and the afternoon switch with a button beside the field, or with the A and P keys. Only digits go in.

A value the mask cannot draw — 24:00, and text the importer could not convert — opens as a plain text field and stays whole, so the person can see what to fix.

A typed hour finishes itself when focus leaves the field, so 18 stores 18:00. Slots below the hour fill with zeros, so 143 stores 14:30. Seconds and fractions are never added to a time that did not carry them.

hourCycle fixes the clock the column draws, h23 for 18:00 and h12 for 6:00 PM, so an app running on one clock shows the same form to everyone. Leave it out and the cell follows the browser of whoever is reading. The mask follows the same clock, so a twenty-four hour column takes 18:00 and a twelve-hour column takes 06:00 with the afternoon switched on. The file import ignores it and goes on reading every form under Formats the importer reads. A column declaring a formatter of its own draws through that function, and the declared clock has no effect there.

A column with this editor is checked against { type: "time" } even when it declares no validator, so a value the importer could not convert is flagged. Declaring a { type: "time" } rule of your own replaces that implicit check.

{ type: "time" }
{ type: "time", hourCycle: "h23" }

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) the editor accepts values outside options. Users add new options inline, both in the value-matching step and in 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 when it is mapped, so a user who wants an off-list value maps it to an existing option or creates a new option for it. Values left unmatched are dropped, and an off-list value becomes an option only when someone creates it. For a column carrying many off-list values, the value-matching drawer offers one action that creates an option for every unmatched value, each an exact copy of the imported value. The SDK leaves membership unchecked, so add a oneOf validator when the value has to stay inside the list. Set enableCustomValue: false for a strict closed enum, where the editor creates no options and every value maps to an existing one.

{ type: "select", options: ["Admin", "Editor", "Viewer"], enableCustomValue: false }

Dropdown where users pick zero or more options. Every cell in the column stores a string[], even when it holds a single value.

{ 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: ";" }

Pasted and filled text is split the same way, with the delimiter detected from the written block itself and delimiter overriding that detection. A block that carries no evidence falls back to the comma, which is what a copy out of the grid puts on the clipboard, so copy and paste round-trip. A split needs one known option among the tokens, so a free-form Smith, Jr that matches nothing lands as a single value. Values outside options are kept while enableCustomValue is true; with enableCustomValue: false they are dropped, and a cell whose every value is off-list keeps what it had.

Find and replace edits one value of the list at a time, so a search stops at the boundary between two values and a replacement that empties a value removes it from the list.

Number input with locale-aware formatting.

{
type: "number",
decimalSeparator: ".",
thousandsSeparator: ",",
}
Field Type Description
decimalSeparator string Decimal point character shown in the grid and the cell editor. Defaults to browser locale.
thousandsSeparator string Thousands grouping character shown in the grid and the cell editor. Defaults to browser locale.

Both govern the grid as well as the cell editor, so a column that declares them reads the same whether the cell is being edited or painted. Declare one and the other still comes from the browser.

These two dress the value on screen. The format a file or a paste is read in comes from the data itself, column by column, so a column declaring a comma still reads a file that writes a point.

Bounds and decimal digits come from the column’s { type: "number" } validator, so they are declared once. min: 0 also closes the field’s minus gate, and decimalPlaces limits what the field accepts as you type.

A column with this editor is checked against { type: "number" } even when it declares no validator, so a value the importer could not reduce to a number is flagged. Declaring a { type: "number" } rule of your own replaces that implicit check.

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, and the function rule covers everything else.

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 and lets submission continue. Invalid rows reach onComplete alongside valid ones, tagged with 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" }

Validates email format.

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

Requires an ISO YYYY-MM-DD value that exists in the calendar, so 2026-02-31 fails. The importer converts recognised date formats to ISO on the way in, so this rule reports the values it could not convert. min and max are inclusive ISO bounds, and they also set the range the cell’s date picker offers.

{ type: "date", min: "2020-01-01", max: "2030-12-31", message: "Invalid date" }

Requires a canonical time, HH:MM, HH:MM:SS or HH:MM:SS.fff, between 00:00 and 23:59:59.999. 24:00 is accepted as the end of the day. The importer converts recognised time formats on the way in, so this rule flags a value it could not convert, 25:00, 24:01, 09:60, 00:30 PM, a value carrying a lettered zone, or a value carrying an offset it could not strip. The cell keeps the text the file brought so the user can see what to fix. min and max are inclusive canonical bounds, and a bound may be written in any of the three precisions. A bound of 09:00 and a cell holding 09:00:00 name the same instant, so the cell passes.

precision says how finely the column is written — "minutes", "seconds" or "milliseconds". A value carrying more than the column declares is flagged and never trimmed, the same way an extra decimal is flagged on a number column. It also caps how far the cell editor lets a person type. Undeclared, the column takes anything down to the millisecond.

{ type: "time", min: "09:00", max: "18:00", message: "Outside opening hours" }
{ type: "time", min: "09:00", max: "18:00", precision: "minutes" }

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

Requires the canonical stored form: plain digits, a dot for decimals, an optional leading minus, no grouping and no leading zeros. 1234.56 and -0.5 pass; 1,234.56, 1,5, 007, .5 and 1e5 are flagged. The importer reduces recognised shapes — currency symbols, percent signs, accounting parentheses, and the file’s own grouping — to that form on the way in, so this rule reports the values it could not reduce.

min and max are inclusive bounds. decimalPlaces is the maximum number of decimal digits, where 0 means integers; a value carrying more is flagged, never rounded.

{ type: "number", min: 0, max: 1_000_000, decimalPlaces: 2, message: "Must be a price" }

min and decimalPlaces also configure the column’s number editor, so the bound is declared once.

Flags duplicate values in this column as errors. Uniqueness is relational, so the SDK checks the value against every other row in the column.

{ 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).

The unique check always runs last. It runs once every other validator on the column passes, wherever { type: "unique" } sits 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 no built-in fits, use { type: "function" }. The fn receives the cell value and the full row. Return a ValidationError to flag a problem, or null when the value is fine.

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

A function rule reads the whole row, so one column can be judged against another. dependentFields sits on the column the user edits and names the columns to recheck, so the rule fires again when the value it reads changes.

[
{
id: "startDate",
title: "Start Date",
editor: { type: "date" },
dependentFields: ["endDate"],
},
{
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,
}],
},
]

Without dependentFields on startDate, editing a start date leaves a stale verdict on the end date beside it.

unique.fn asks whether a value already exists in your system. { type: "asyncFunction" } covers every 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. The SDK holds back any cell that fails a sync validator, duplicates another cell inside the file, or is empty (null/""). 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. Once it rejects, the SDK marks the remaining cells unverified and does not re-ask.
  • The SDK handles staleness. Checks are never cancelled. When the data changes mid-check, outdated verdicts are 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), so honoring it saves your backend work.
  • Opening a file triggers a full initial check of all values in async-validated columns. Your endpoint receives 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. The two may be combined, and 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 and 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 everything except 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. The two may be combined, results are unioned, and chunks may arrive in any order.

// Simple client, no index bookkeeping: send back an array of the same length.
{
type: "asyncFunction",
fn: async (cells) => {
return api.checkSkus(cells.map(c => ({ sku: c.value, region: c.row.region })));
},
}
// Advanced client, batches itself and 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 keyed by index 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” on its own cannot say which cell it means.

Use unique.fn for uniqueness. A uniqueness check built on asyncFunction misses in-file duplicates, never clears itself when the conflicting value leaves the file, and gets no value dedupe or verdict caching.

Type string[]

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, receiving one option at a time. The SDK formats each value then joins the results, so an option-to-label formatter ((v) => labels[v]) works unchanged.

Search reads the formatted value, so a query for $1200 finds a cell storing 1200. Find & Replace writes back to the stored value and takes a match only where it sits inside the data: a query for text the formatter added ($ on its own) reports no results, and a formatter that drops or reorders characters leaves its column unreplaceable. Row filters go on matching the formatted value in every case.

The formatter dresses the grid for the person reading it, so an export skips it and writes the stored value. A multiselect column writes its stored codes joined by the column delimiter, so a file the SDK exports loads back into the same options.

Type (value: unknown) => unknown

Transform a value on its way into the store.

{ transformer: (v) => typeof v === "string" ? v.trim() : v }

It runs on every value arriving from outside the editor. loadData, a file import, a remote source, a custom format, and a paste of text copied from another app all call it. A value already in the grid stays as it is, so a manual edit, a fill, a paste of cells copied from the grid itself, undo, and redo never call it. The person editing a cell sees what they typed.

The value arrives in the shape the cell is stored in. A number and a date canonicalize first, so a transformer on a date column receives 2026-08-19 where the file carried 19.08.2026. A select value resolves through value matching first, so the transformer receives the option the user confirmed. The matching keys stay the values the person saw on that screen.

On a multiselect column the transformer runs once per token, the way formatter already reads that column.

{
id: "interests",
editor: { type: "multiselect", options: ["Fire Safety", "First Aid"] },
transformer: (v) => String(v).trim(),
}

A token the transformer empties leaves the list, and two tokens it makes equal collapse into one. A cell holding an empty list calls nothing.

The same transformer runs over the originalValues you state in seeded previous changes, so a row whose stored value differs from your original only by the transform reads as untouched.

Type ColumnFilter

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 }
| { type: "time-range"; label?: string };

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.

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

Two time fields for the earliest and latest time of day. Bounds are inclusive and compare by the instant, so a bound of 09:00 matches a cell holding 09:00:00. The fields take the column’s clock and precision. A cell the importer could not convert, and an empty cell, drop out of the result while the filter is on.

{ type: "time-range", label: "Shift Start" }
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.

Type boolean | "all" | "default"

Controls whether cells in this column are locked.

  • true and "all" lock the column for every row.
  • "default" locks it for default-source rows only. Rows the user added by hand, duplicated, or imported stay editable.
  • false and undefined leave the column open.

A column locked in configuration stays locked. The UI offers no way to unlock it.

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" },
validators: [{ type: "number", min: 0, decimalPlaces: 2 }],
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" },
dependentFields: ["endDate"],
},
{
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,
}],
},
{
id: "notes",
title: "Notes",
size: 300,
locked: false,
pinnable: false,
},
];