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;
mappable?: boolean;
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: "email" }
| { type: "url" }
| { type: "phone"; defaultRegion?: string }
| { type: "date" }
| { type: "time"; hourCycle?: "h12" | "h23" }
| { type: "select"; options: string[]; enableCustomValue?: boolean }
| { type: "multiselect"; options: string[]; enableCustomValue?: boolean; delimiter?: string }
| { type: "country"; multiple?: boolean; enableCustomValue?: boolean; delimiter?: string; only?: string[] }
| { type: "currency"; enableCustomValue?: boolean; only?: string[] }
| { type: "usState"; multiple?: boolean; enableCustomValue?: boolean; delimiter?: string; only?: string[] }
| { type: "number"; decimalSeparator?: string; thousandsSeparator?: string }
| { type: "boolean" };

Plain text input.

{ type: "text" }

Plain text input for an address. The column is checked against { type: "email" } even when it declares no validator, so not-an-email is flagged. Declaring a { type: "email" } rule of your own replaces that implicit check and carries your message.

{ type: "email" }
  • Reads on its own: a value from a file, loadData or a paste loses its surrounding spaces and gets its domain lower-cased, so Ann@ACME.com is stored as Ann@acme.com. The part before the @ stays as written.
  • Keeps what it cannot read: not-an-email stays in the cell, trimmed, and the rule flags it.
  • Typed as is: a value typed into the cell is stored the way it was typed and judged by the rule.

Add { type: "unique" } for a column that must not repeat, the same way as on any other column.

Plain text input for a web address. The column is checked against { type: "url" } even when it declares no validator, so not a site is flagged. Declaring a { type: "url" } rule of your own replaces that implicit check and carries your message.

{ type: "url" }
  • Completes a bare host: a value from a file, loadData or a paste loses its surrounding spaces, and a host with no scheme gets https:// in front, so acme.com and www.acme.com/team are stored as https://acme.com and https://www.acme.com/team. A value that already carries a scheme stays as written, and so does the rest of the address: no trailing slash is added and the case is kept.
  • Keeps what it cannot read: not a site, acme and a number stay in the cell, trimmed, and the rule flags them.
  • Typed as is: a value typed into the cell is stored the way it was typed and judged by the rule, so a typed acme.com is flagged until it carries its scheme.

Plain text input for a phone number. The column is checked against { type: "phone" } even when it declares no validator, so abc is flagged. Declaring a { type: "phone" } rule of your own replaces that implicit check and carries your message.

{ type: "phone" }
{ type: "phone", defaultRegion: "DE" }
  • Stores E.164: a value from a file, loadData, a paste, a fill or a formula is stored as +4915112345678, whatever spaces, dots, dashes or brackets it came with. Export and onComplete carry the same form.
  • Shows the number spaced: the grid draws +49 1511 2345678, and the cell opens on the stored value. A column declaring a formatter draws through that function.
  • defaultRegion is the ISO 3166-1 alpha-2 country a number without a country code belongs to, so 0151 12345678 in a "DE" column is read as a German number. Without it, only a number carrying + is read. A region the importer does not know is ignored with a warning in the console.
  • Keeps what it cannot read: a number with an extension, two numbers in one cell, letters, and a length no country has stay in the cell, trimmed, and the rule flags them.
  • Typed the same way: a value typed into the cell goes through the same reading, so 0151 12345678 typed into a "DE" column is stored as +4915112345678, and abc is stored as typed and flagged.

A run of digits with no plus in a column with defaultRegion is read as a national number, so 4915112345678 in a "DE" column is read as a German number that begins with 49. Keep the plus in the file, or leave defaultRegion off a column whose numbers all carry a country code.

Add { type: "unique" } for a column that must not repeat. Two spellings of one number count as one value.

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.

Dropdown over the 250 ISO 3166-1 codes (249 assigned plus XK for Kosovo). The cell stores the alpha-2 code in upper case, DE; the grid shows the country name in the interface language through the browser’s Intl.DisplayNames, Germany under locale: "en", ألمانيا under locale: "ar". The code reaches the export file and onComplete.

{ type: "country" }
  • Reads on its own: alpha-2 DE, alpha-3 DEU, numeric 276 (and 4 where a spreadsheet dropped the zeros), the name in 26 languages, and alternative forms such as UK, Turkey, Swaziland. Case, accents, apostrophes and hyphens make no difference. This applies to import, paste, loadData and formulas alike.
  • Corrects typos on the value-matching step only: a value one letter away from a name in English, Spanish, German, French, Italian, Russian, Arabic or Chinese maps to that country and gets a Corrected tag, so Grmany becomes Germany. It corrects only when a single country is one edit away (Iran stays beside Iraq) and never touches a value under four characters or one with anything besides letters and spaces (N/A, TBD). A name that means two countries, Korea, Congo, waits for the person with both candidates tagged Possible match.
  • Keeps what it cannot read: the value stays in the cell as text and the built-in { type: "country" } rule flags it. The list is closed by default; enableCustomValue: true adds the Create option, and a created value passes validation and reaches onComplete as typed.
  • Takes your dictionary first: synonyms={{ values: { RU: ["Rossiya"] } }} maps before the built-in reading, and a pair the person confirms comes back in result.learnedSynonyms with the code as its target.
  • only narrows the column to the codes you list, alpha-2 in any case, so a shop that ships to three countries writes only: ["DE", "FR", "IT"]. The dropdown, the matching step, the built-in rule, the export sample and the chat all work from that list. A value that reads to a code outside it stays in the cell as text and is flagged, the way Wakanda is, so Switzerland on a column of EU codes gets no correction and no candidate. The list also settles a name of two countries when it takes one of them, so Korea reads as KR under only: ["KR", "JP"]. The dropdown still sorts by name; onValueMatch and the export sample keep your order. A code the ISO list has not got is dropped with a console warning, an empty array means the whole list, and the array is read once, so keep it stable across renders the way you keep options.
  • multiple stores a string[] of codes, paints Germany, France, exports DE, FR (or joined with delimiter), splits an imported or pasted cell into tokens and reads each on its own, and validates every element.
{ type: "country", multiple: true, delimiter: ";" }

There is no nationality type: a column of demonyms is a select over your own list plus synonyms.values.

Dropdown over the 178 codes of the current ISO 4217 list. The cell stores the three-letter code in upper case, USD, and the grid shows that code in every interface language. The code reaches the export file and onComplete.

{ type: "currency" }
  • Reads on its own: the code usd, numeric 840 (and 8 where a spreadsheet dropped the zeros), the name in 26 languages, the ISO name Pound Sterling, and the sign, $, €, zł, R$, Fr.. A bare $ is USD, ¥ is JPY, £ is GBP, the way English prints them. This applies to import, paste, loadData and formulas alike.
  • Corrects typos on the value-matching step only: a value one letter away from a name in English, Spanish, German, French, Italian, Russian, Arabic or Chinese maps to that currency and gets a Corrected tag, so Eruo becomes EUR. It corrects only when a single currency is one edit away and never touches a value under four characters or one with anything besides letters and spaces (GPB, N/A). A word that names several currencies, dollar, ruble, or a shared sign, kr, Rs, waits for the person with every candidate tagged Possible match.
  • Keeps what it cannot read: a withdrawn BGN, a crypto BTC or a TBD stays in the cell as text and the built-in { type: "currency" } rule flags it. The list is closed by default; enableCustomValue: true adds the Create option, and a created value passes validation and reaches onComplete as typed.
  • Takes your dictionary first: synonyms={{ values: { NOK: ["kr"] } }} maps before the built-in reading, and a pair the person confirms comes back in result.learnedSynonyms with the code as its target.
  • only narrows the column to the codes you list, in the order the dropdown shows them, so a shop that bills in euros writes only: ["EUR"]. The dropdown, the matching step, the built-in rule, the export sample and the chat all work from that list. A value that reads to a code outside it stays in the cell as text and is flagged, so $ and CHF on that column get no correction and no candidate. The list also settles a shared sign or name when it takes one of its currencies, so $ and dollar read as USD under only: ["USD", "EUR"] and kr reads as NOK under only: ["NOK"]. A code the ISO list has not got is dropped with a console warning, an empty array means the whole list, and the array is read once, so keep it stable across renders the way you keep options.

Dropdown over the 62 USPS codes: the 50 states, DC, the territories and the military codes. The cell stores the two-letter code in upper case, CA; the grid shows the English name, California, in every interface language. The code reaches the export file and onComplete.

{ type: "usState" }
  • Reads on its own: the code in any case, the name, and the forms files carry, Calif., N.Y., N. Carolina, US-TX, State of Texas, Washington, D.C.. This applies to import, paste, loadData and formulas alike.
  • Corrects typos on the value-matching step only: Califronia becomes CA with a Corrected tag. A word that names several states, Carolina, Dakota, waits for the person with every candidate tagged Possible match.
  • Keeps what it cannot read: Ontario or a TBD stays in the cell as text and the built-in { type: "usState" } rule flags it. The list is closed by default; enableCustomValue: true adds the Create option.
  • only narrows the dropdown, the matching step and the built-in rule to the codes you list, so a carrier that delivers to three states writes only: ["CA", "NV", "AZ"]. A value that reads to a code outside the list is flagged. Keep the array stable across renders the way you keep options.
  • multiple stores a string[] of codes, paints California, New York, exports CA, NY (or joined with delimiter), splits an imported or pasted cell into tokens and reads each on its own, and validates every element. A name with a comma inside, Washington, D.C., stays one state.
{ type: "usState", multiple: true, delimiter: ";" }

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.

A yes-or-no value. The cell stores a real true or false, the grid draws a checkbox, and the boolean reaches onComplete and the JSON and XLSX exports typed. CSV, TSV and XML write TRUE and FALSE, the way Excel does.

{ type: "boolean" }
  • Reads on its own: true, yes, y, 1, t, on and + as true; false, no, n, 0, f, off and - as false; and the word Excel writes for TRUE and FALSE in 18 languages, so WAHR, VRAI and ИСТИНА read as true and FALSCH, FAUX and ЛОЖЬ as false. Case and surrounding spaces make no difference. A real boolean or a 1/0 number handed over by loadData or an XLSX cell reads the same way. This applies to import, paste, loadData, formulas and find and replace alike, so replacing true with no stores false.
  • An empty cell is false: a blank in the file, a null or a missing field in loadData, and a column the file never held all arrive as false, so a required rule has nothing to flag on this column.
  • Keeps what it cannot read: maybe stays in the cell as text and the built-in { type: "boolean" } rule flags it. The column has no value-matching step and no typo correction; the reader takes the words above and nothing else.
  • Toggles in place: a click on the checkbox, Space or Enter on the focused cell flips it, and Delete sets false. The click counts on release over the checkbox, the way a native one does, so a press that drags away selects a range and a click with Shift or Cmd held extends the selection, both leaving the value alone. There is no cell editor to open, so a letter typed over the cell does nothing. A cell holding a word the dictionary has not got is drawn as red text; one toggle turns it into true.
  • Has no text to format: the checkbox is the whole rendering, so formatter does not apply to this column.
  • Reads as a checkbox: a screen reader hears each cell as a checkbox with its checked state.

Sorting is alphabetical, false before true, and the Filters panel lists the two values.

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 http or https address whose host has a dot, so https://acme.com and http://192.168.0.1/admin pass while mailto:a@b.co, ftp://acme.com, https://acme and a bare acme.com are flagged. A column with the URL editor is checked against this rule even when it declares no validator, and that editor completes a bare acme.com before the rule sees it. An empty cell passes.

{ type: "url", message: "Enter a web address" }

Requires the E.164 form of a number some country can have, +4915112345678, so +49 1511 2345678, 0151 12345678 and abc are flagged. A column with the Phone editor is checked against this rule even when it declares no validator, and that editor reads a number into E.164 before the rule sees it. An empty cell passes.

{ type: "phone", message: "Enter a phone number with its country code" }

When message is omitted, the error text is localized via the translations prop (dataEditor.validation.invalidPhone).

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

Requires an ISO 3166-1 alpha-2 code from the country list, or from the column’s only when it declares one, so a name the importer could not read to a code is flagged. A column with the country editor is checked against this rule even when it declares no validator. Declaring a { type: "country" } rule of your own replaces that implicit check. On a multiple column every element must be a code. A value the person created under enableCustomValue passes.

{ type: "country", message: "Pick a country from the list" }

When message is omitted, the error text is localized via the translations prop (dataEditor.validation.invalidCountry).

Requires an ISO 4217 code from the currency list, or from the column’s only when it declares one, so a name or a sign the importer could not read to a code is flagged, and so is a code the list has withdrawn. A column with the currency editor is checked against this rule even when it declares no validator. Declaring a { type: "currency" } rule of your own replaces that implicit check. A value the person created under enableCustomValue passes.

{ type: "currency", message: "Pick a currency from the list" }

When message is omitted, the error text is localized via the translations prop (dataEditor.validation.invalidCurrency).

Requires a USPS code from the US state list, or from the column’s only when it declares one, so a name the importer could not read to a code is flagged. A column with the US state editor is checked against this rule even when it declares no validator. On a multiple column every element must be a code. A value the person created under enableCustomValue passes.

{ type: "usState", message: "Pick a state from the list" }

When message is omitted, the error text is localized via the translations prop (dataEditor.validation.invalidUsState).

Requires a real true or false, so a word the importer could not read to one, maybe, is flagged. A column with the boolean editor is checked against this rule even when it declares no validator. Declaring a { type: "boolean" } rule of your own replaces that implicit check. An empty cell passes, and on this column an empty cell is stored as false anyway.

{ type: "boolean", message: "Yes or no" }

When message is omitted, the error text is localized via the translations prop (dataEditor.validation.invalidBoolean).

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

{ type: "regex", pattern: "^[A-Z]{2}-\\d{4}$", message: "Use the AB-1234 form" }

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 the column is offered as a target on the column-matching step.

Set false for a field your onRowImport callback fills. The column leaves the matching dropdowns, auto-matching, the onColumnMatch answer, value matching, and the primary-key candidates, because a match there would be overwritten by the callback anyway. In the editor and in the result it stays an ordinary column, so the person importing sees what the callback wrote and can fix a cell it got wrong.

Hide only fields no file ever carries directly. A hidden column cannot receive a match. When a file arrives with the name already split, its Last name header has no target, so a hidden lastName stays empty unless the callback also handles that file shape.

A primaryKey naming a hidden field never receives a match, so imported rows are appended instead of merged. The console warns about it.

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,
editor: { type: "email" },
validators: [
{ type: "required", message: "Email is required" },
{ type: "unique" },
],
},
{
id: "website",
title: "Website",
size: 200,
editor: { type: "url" },
},
{
id: "phone",
title: "Phone",
size: 180,
editor: { type: "phone", defaultRegion: "US" },
},
{
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,
},
];