Build your own cell type
The CellType contract, a keypoint checklist distilled from every lesson the built-in types learned the hard way, and a full worked currency example.
The mental model
A cell type is one object: { Cell, Editor, toText, fromText, clearValue, isEmpty, compare?, align?, numeric?, toSearchText?, validate?, activate?, href?, filter? }. Its four
value-pipeline methods drive clipboard, the fill handle, quick-clear delete, search, sort, and
import/export. The optional behavior fields drive the grid's built-in behaviors (commit
validation, quick-search text, in-place edit gestures, and the filter menu's operators and
value input) so the grid reads them from your type instead of from the type's name. One generic
implementation runs against the contract for every type. Get fromText wrong, and every
consumer inherits the bug.
The contract, member by member
export type CellType<TData = unknown, TValue = unknown, TOptions = unknown> = {
Cell: FC<CellRenderProps<TData, TValue, TOptions>>;
Editor: FC<CellEditorProps<TData, TValue, TOptions>>;
toText(value: TValue, options?: TOptions): string;
toDisplayText?(value: TValue, options?: TOptions): string;
fromText(text: string, options?: TOptions): TValue;
clearValue(options?: TOptions): TValue;
isEmpty(value: TValue): boolean;
compare?(a: TValue, b: TValue, options?: TOptions): number;
align?: "left" | "right" | "center";
numeric?: boolean;
toSearchText?(value: TValue, options?: TOptions): string;
validate?: ((value: TValue, row: TData) => string | null) | StandardSchemaV1<TValue>;
activate?: (value: TValue, options?: TOptions) => TValue;
href?(value: TValue, options?: TOptions): string | null;
filter?: {
operators?: readonly FilterOperator[];
match?: (value: unknown, filter: FilterSpec) => boolean;
valueInput?: "text" | "number" | "date" | "boolean" | "choices" | ComponentType<FilterValueInputProps>;
};
};Cell: read-mode rendering. It receives{ value, row, rowId, columnId, viewRowIndex, column, options, isActive }. Theoptionsprop carries the column'soptions, typed asTOptionswhen you parameterize the type with it. It runs for every visible cell on every window mount. This is a hot path (code that runs very often, so its per-call cost matters), covered more below.Editor: edit-mode rendering. It receives{ value, initialText?, row, rowId, columnId, column, options, onChange, commit, cancel, pending?, rejectionCount? }.commit(movement?)writes the value back throughonChange, and tells the grid where the active cell goes next.cancel()discards the edit entirely.pendingandrejectionCountmatter only if thevalidateof the column can be an async Standard Schema. See Editing & cell types and the checklist below.toText: the canonical serialization. Clipboard copy and export use it unconditionally. It must round-trip throughfromTextfor every value that the type can hold. Quick search uses it when the type declares notoSearchText. Filter does NOT use it. See the value-pipeline note below.toDisplayText(optional): a display-only formatted view, for example locale-formatted dates or currency symbols. It is never round-tripped, and only the cell's display rendering reads it: clipboard and export calltoTextdirectly, search readstoSearchText ?? toText, and filter runs on the raw value (see the value-pipeline note below). When you omit it, the type falls back totoText(see thedisplayText()helper, exported from thedata-gridentry point). To render that text like a built-in cell, useCellSpanfrom the same entry point. It takestextand an optionalalign("left","right", or"center"), truncates the text, and applies the alignment.fromText: parses pasted, imported, or typed text intoTValue. It must never throw. Paste and import feed it arbitrary, untrusted text: a stray clipboard fragment, a spreadsheet export from a different tool, or a typo made by a user. For anything it cannot parse, return the result ofclearValue(), notundefinedand not a thrown error. So a bad paste degrades to the cleared cell, and the rest of the paste keeps going.clearValue: the empty value of the type ("",null,false, and so on). Quick-clear delete, the garbage fallback offromText, and fill-clear all use it.isEmpty: whether a value counts as empty. See the checklist below. It decides what a Ctrl+A "select all data" region treats as non-blank, and what the fill handle treats as a fillable gap.compare: the sort comparator, used when the column declares thistype. When you omit it, the default is a numeric-aware locale collation of the RAWString(value), not oftoTextoutput. Empty values (perisEmpty) sort last in both directions.compareonly ever sees two non-empty values.align: text alignment for the defaultCellrendering."right"reads better withtabular-numsfor numeric-like types.numeric: renders the cell's digits withtabular-nums, the fixed-width numerals that keep columns of numbers aligned.numberanddatedeclare it.toSearchText: the quick-search text for a stored value. The default istoText.selectdeclares it, so aselectcolumn searches the choice LABEL, not the stored value. Filter never uses it. Filter matches the raw value.validate: the default validator for columns of this type when the column declares novalidateof its own. Same two forms as the column'svalidate(a function or a Standard Schema. See Editing & cell types: Validation). Every validation path resolvescolumn.validate ?? cellType.validate: a single-cell commit, a paste, a fill,updateCells, and import.activate: gives the cell an in-place edit gesture instead of an editor. Enter/F2, a stationary click, and a double-click on anactivatecell commitactivate(value, options)directly, with no edit session (soonEditStartandonEditEndnever fire).checkboxdeclares(value) => !value. Withoutactivate, the same gestures open the editor.href: the link target for a stored value. The grid routes every result throughsafeHref(exported from thedata-gridbarrel) before it renders it or opens it: onlyhttp,https, andmailtosurvive, and a type cannot widen the list.urldeclares it; theopenLinkaction (alt+Enter) opens the active cell's safe href.filter: the filter capabilities of the type, used when the column declares none of its own.filter.operatorsreplaces the operator list the filter menu offers for the type's columns.filter.matchis a raw-value predicate that replaces the built-in text matcher, with the same contract as the column'sfilterMatch.filter.valueInputpicks the filter row's value widget:"text","number","date","boolean","choices", or your own component. Each member falls back to the type-name default when absent. Afilter.matchorfilterMatchpredicate seesisNoneOfonly when the column offers that operator. Add it tofilter.operatorsorfilterOperatorsfor a custom type.
A component in filter.valueInput renders for every operator that takes a value. It receives
FilterValueInputProps: { column, operator, value, onValueChange }. value is a string, a
[from, to] pair for isBetween, or a string[] for isAnyOf and isNoneOf:
function MoneyFilterInput({ operator, value, onValueChange }: FilterValueInputProps) {
if (operator === "isBetween") {
const [from = "", to = ""] = Array.isArray(value) ? value : [];
return (
<>
<input type="number" step="0.01" value={from} onChange={(e) => onValueChange([e.target.value, to])} />
<input type="number" step="0.01" value={to} onChange={(e) => onValueChange([from, e.target.value])} />
</>
);
}
if (operator === "isAnyOf" || operator === "isNoneOf") {
// A comma-separated list of amounts
const list = Array.isArray(value) ? value.join(",") : "";
return <input value={list} onChange={(e) => onValueChange(e.target.value.split(",").map((part) => part.trim()))} />;
}
return <input type="number" step="0.01" value={typeof value === "string" ? value : ""} onChange={(e) => onValueChange(e.target.value)} />;
}
const moneyCellType: CellType<Row, number | null> = {
// Cell, Editor, toText, fromText, clearValue, and isEmpty as usual
filter: { valueInput: MoneyFilterInput },
};Try each column's editor: Read-only refuses edits, Validated rejects an age under 18, Currency edits the raw number behind its locale format, Link opens with alt+Enter (its javascript: row stays plain text), Notes opens a multi-line editor, and renderCell shows a progress bar.
Keypoint checklist
The built-in types (text, number, checkbox, select, date) reflect the points below.
Each point states the lesson plus the reason for it.
Value pipeline
The pipeline contract, one surface per feature: display = toDisplayText ?? toText ·
clipboard/export = toText · sort = compare (default: numeric-aware collation of the
raw String(value)) · quick search = toSearchText when the type declares it, else
toText · filter = the raw value, through filter.match or the built-in text
matcher · fill = raw value.
- Search matches text; filter matches the stored value. Quick search matches the type's
toSearchTextwhen it declares one, and itstoTextotherwise. A column without a declared type searches the rawString(value). Filter matches the raw value, through the type's or column'sfilter.matchwhen declared, else the built-in text matcher. So a customtoTextchanges what copy, export, and search produce, but NOT what filter matches. Consequences:- A
selectcolumn searches the displayed LABEL (itstoSearchText). Its filters match the stored value, soequals,isAnyOf, andisNoneOftake choice values, not labels. - A
numbercolumn withdecimalssearches the rounded value. - A
datecolumn with adisplayFormatmatches the ISO string.
- A
fromTextmust never throw. Paste and import feed it arbitrary text. A thrown error breaks the whole paste, not only one cell. Garbage in produces the result ofclearValue()out.- Store values as serializable primitives, not class instances: an ISO
yyyy-mm-ddstring, not aDate, and a plain number, not aDecimalorMoneyobject. This makescomparea plain comparison, makes clipboard and JSON round-trips trivial, and avoids timezone drift entirely (aDateobject carries a timezone, a string does not). The local-midnight parsing of the date type, usinggetFullYear(),getMonth(), andgetDate(), nevertoISOString(), exists specifically to avoid a UTC day-shift when it converts a locally-parsed date back to text. isEmptysemantics are deliberately per-type. Choose them consciously. Fortext,""IS empty. Fornumber,0is NOT empty (onlynullis). Forcheckbox,falseis NOT empty (onlynullis). Getting this wrong changes what a Ctrl+A region treats as blank, and what the fill handle treats as a gap to fill. If a number column treats0as empty, the fill handle skips real zero values.- The
toDisplayTextfallback chain (toDisplayText ?? toText, see thedisplayText()helper, exported from thedata-gridentry point) keeps read-view formatting separate from the canonical text. Format for humans intoDisplayText, and keeptoTexta plain, parseable, round-trippable string. Clipboard and export calltoTextdirectly and never seetoDisplayTextat all. So a decorative display format can never leak into a CSV export or break a later paste. - The value and label duality (see
select): store the value, display the label, and accept both on paste. ThetoTextofselectrenders the option's label, what a spreadsheet user expects to copy. ButfromTextresolves pasted text against option values first, then falls back to case-insensitive label matching. So both a raw value and a copied label paste back correctly. - Empty placement is handled outside
compare. Values yourisEmptymarks as empty sort last in both directions, for every type.comparenever receives an empty value, so it does not need a null branch.
Rendering & performance
- Hoist and cache expensive per-render objects. Do not construct them inside
Cell.Cellrenders once per visible cell on every window mount, and mounts happen continuously during scroll. So a fling multiplies whatever is inside by however many rows cross the viewport in that frame. ThedateFormatterCacheof thedatetype exists because constructing anIntl.DateTimeFormatmeasured ~38us, against ~0.7us to reuse a cached instance, a ~54x difference. Uncached, this dominated fling-scroll frame cost in the 100k-row benchmark of this repository (long tasks per frame: 224 down to 1, once cached). - Key caches on serialized option values, not on object identity. A column's
optionsobject literal is commonly re-created on every render. Writingoptions: { displayFormat: {...} }inline in a column definition is the normal case, not the exception. An identity-keyed cache (Map<object, ...>keyed by reference) never hits, and this defeats the cache entirely. Key on`${locale}|${JSON.stringify(format)}`(or an equivalent) instead, exactly likedateFormatterCacheand thecurrencyFormatterCacheof the worked example below. - Avoid allocations in the
Cellrender path beyond what is unavoidable. Every object literal, array, or closure created insideCellis created again on every scroll-driven remount. TreatCelllike ashouldComponentUpdate-sensitive component in any virtualized list. - Keep formatting SSR-deterministic. Pin locales explicitly.
Intl.DateTimeFormatandIntl.NumberFormatfall back to the runtime's default locale when you pass none. The default locale of a Node SSR process commonly differs from that of the browser (Jul 5, 2026against5 Jul 2026). This produces different rendered text on the server and the client for identical column configuration, and React throws a hydration mismatch. Theoptions.localeof thedatetype defaults to"en-US"specifically so that the server and the client always agree, independent of the OS or browser locale of either machine. An explicitlocaleoption still overrides it.
Editor UX
- A text-input editor starts from
useCellEditor, not from scratch. It owns the draft state (seeded frominitialText, or from your codec'stoDraft(value)when there is none), the end-caret focus, the one-shot commit guard, the re-arm after a rejected commit, the Enter/Escape/blur bindings (inactive during IME composition), and thearia-label,aria-busy, anddir="auto"attributes. You supply one codec object (toDraftandtoValue) and spread the returned bindings onto your input. The checklist points below state the rules the hook already implements, for the editors that do not use it (popover, checkbox, and similar). - If your editor commits from more than one code path, use the commit guard
(
useCommitGuard). An editor typically has at least two ways to end an edit: Enter, then a blur that follows it, or React StrictMode's mount-cleanup-mount double-invoke in dev. Without a guard, the second path firescommitoronChangeagain on an edit that already committed. The guard is oneuseRefflag:tryCommit()returnstrueonce, andfalseafter that. - Honor
pendingandrejectionCountif your cell type can carry an async Standard Schemavalidate.CellEditorProps.pendingis true while an async schema is resolving. Set your inputreadOnly, neverdisabled(a disabled input cannot receive Escape or blur, which silently blocks cancellation).rejectionCountincrements on every rejected commit attempt (sync or async) and resets when a new edit session starts, not on every transition wherependingclears for any reason (this also fires on Escape right before unmount). Re-arm your commit guard offrejectionCount, or a stray blur during teardown can fire a second, stale commit through the unguarded path. See the editors oftext,number,select, anddatefor the pattern. - Escape cancels, and Enter commits and moves. The movement direction is part of the contract.
commit({ dx: 0, dy: 1 })for Enter moves down.commit({ dx: 0, dy: 0 })for blur, click-away, or picking an option, stays in place. The built-intextandnumbereditors bind only Enter and Escape. Tab is a native blur that commits in place while the browser moves focus. It never moves the active cell. Leave Tab unbound, or commit it with{ dx: 0, dy: 0 }, to match;commit({ dx: 1, dy: 0 })on Tab moves the active cell out from under the browser's own focus move. Escape callscancel()and must NOT callonChangeat all. The edit is discarded, not committed with the old value. - Focus discipline: focus and move the caret to the end on mount, seeded from
initialTextfor type-to-replace. Grid activation and its defaults are on Edit activation.useSeedFocushas the pattern. - Popover and portal editors need
data-grid-cell-editoron their portaled content, or an outside-click ends the edit instantly.PopoverandSelectcontent portals todocument.body, entirely outside the DOM subtree of the cell. But React re-dispatches the events of a portaled element through its logical (component-tree) parent, not its DOM parent. So a click inside the floating content still reaches the outside-click handler of the grid. That handler checksevent.target.closest("[data-grid-cell-editor]")and stops when it matches. Every popover editor (select,date) marks itsPopoverContentorSelectContentwithdata-grid-cell-editor=""for exactly this reason. Skip the attribute, and picking a date or opening the select's dropdown looks like a click-away. It commits or cancels the edit before the user can interact with it. The attribute also decides where focus goes after the commit: without it, focus does not return to the cell. - Popover editors: an explicit pick commits, and closing without one cancels.
dateandselectcommit on an explicit pick (a calendar day, an option). Closing the popover any other way (outside click, focus loss, Tab away) callsonOpenChange(false), which runscancel()when no pick has committed yet, so the edit is discarded, including text typed into the date input. Escape is the same hard cancel. Both gestures close the popover. Only a pick commits. - Display-only cells, with no text-editable value, route interaction through the grid's own
commit path, never through DOM events on the control itself. The
Cellofcheckboxrenders shadcn'sCheckboxwithpointer-events-noneandtabIndex={-1}, so it never receives a click.checkboxdeclaresactivate: (value) => !value, and the interaction layer of the grid commits that result throughcommitCellValuedirectly, so the control only reflects the resultingvalueprop. Any type can declareactivatefor the same in-place gesture.startEditingopens no session on a type that declaresactivate, so theEditorofcheckboxdoes not mount through a grid gesture or action.CellTypestill requires anEditor. TheEditorofcheckboxmounts only for a session that code opens directly through the store (store.setState): it callscancel()in an effect and renders nothing. - Build editor UI from the shadcn primitives of the project, not from raw HTML controls. Reach
for
Input,Select,Popover,Calendar, and similar components from@/components/ui/*, the same way the built-in types do, so your cell type matches the rest of the design system.
Typing
-
Type your options through
GridCellTypesaugmentation, so thatdefineColumnsnarrowsoptions, and the inferred value type of the column, the same way it does for the five built-in types:declare module "@/components/data-grid/types" { interface GridCellTypes { currency: { value: number | null; options: { currency: string } }; } }Adjust the module specifier to wherever your
components.jsonaliases actually placedtypes.ts. Without this,type: "currency"on a column definition still works at runtime, butoptionsand the value type of the column fall back tounknownat the type level. You lose the compile-time catches thatdefineColumnsgives every built-in type, for a typo in an option key and for a typo in a type key. -
Wrap your type in
defineCellTypeonce the augmentation is in. The helperdefineCellType("currency", currencyCellType)adds a compile-time check at the definition: the key must be a registered cell-type key, and the type's value and options must match that key's declaredGridCellTypesentry. A typo in the key, or a drifted entry, fails at the definition, not at every column that uses the type. It costs nothing at runtime and returns the entry as-is, ready for thecellTypesregistry. -
The
cellTypesregistry is untyped in value. Its type isRecord<string, AnyCellType | null>: an entry's shape is not checked against its key's declared type, so a mismatched value or options type compiles. Per-key safety comes fromdefineColumns,defineCellType, and the augmentation above, not from the registry. Anullentry removes a type from the registry. Consider acolumn.typestring that resolves to no registry entry, for example a mistyped registry key. The cell then renders with the built-intexttype, and editing that cell does nothing. In development, the grid logs one warning per column that names the column and the missing type. -
A plain
ColumnDef<TData, TValue>never cross-checksaccessorKeyagainstTValue. Accessor/value cross-checking exists only ondefineColumns'TypedColumnDef. A hand-writtenColumnDef<Product, number | null>withaccessorKey: "name"compiles; the value type is whatever you declare. UsedefineColumnswhen your row type is known. -
Read the typed
optionsprop in cell components. The grid passes the column'soptionsintoCellandEditoras theoptionsprop, typed asTOptionswhen the type is parameterized with it. Parameterize your components with your options type (CellRenderProps<unknown, number | null, CurrencyOptions>) and destructureoptionsdirectly. No cast is needed. The worked example below applies this toCurrencyOptions.
Worked example: a currency cell type
Edit a USD or EUR price cell and type a value with a currency symbol (e.g. "$1,234.56") and press Enter: the custom cell type strips the symbol back to a plain number before it commits.
Storage is a plain number | null, never a formatted string and never a Money class (see
"store values as serializable primitives" above). Display is a cached, locale-formatted currency
string. fromText strips currency symbols and thousands separators, so a pasted "$1,234.56"
round-trips into 1234.56.
type CurrencyOptions = { currency: string; locale?: string };
// Keyed on serialized options, not object identity — the `options` literal is re-created every render.
const currencyFormatterCache = new Map<string, Intl.NumberFormat>();
function getCurrencyFormatter(locale: string, currency: string): Intl.NumberFormat {
const key = `${locale}|${currency}`;
let formatter = currencyFormatterCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, { style: "currency", currency });
currencyFormatterCache.set(key, formatter);
}
return formatter;
}
// commas are thousands separators when "." also appears (US grouping) and dropped; a lone comma
// is treated as the decimal separator (EU style) and normalized to "."
function stripCurrencySymbols(text: string): string {
const kept = text.replace(/[^0-9.,-]/g, "");
return kept.includes(".") ? kept.replace(/,/g, "") : kept.replace(",", ".");
}
function formatCurrencyDisplay(value: number | null, options?: CurrencyOptions): string {
if (value == null) return "";
// pinned default (not the runtime's locale): server/browser Intl defaults can differ -> SSR hydration mismatch
return getCurrencyFormatter(options?.locale ?? "en-US", options?.currency ?? "USD").format(value);
}
function CurrencyCell({ value, options }: CellRenderProps<unknown, number | null, CurrencyOptions>) {
return (
<span className="block w-full truncate tabular-nums">
{formatCurrencyDisplay(value, options)}
</span>
);
}
// useCellEditor owns the draft state, seed-focus, commit guard, and Enter/Escape/blur —
// the same primitives the built-in `number` editor uses
function CurrencyEditor(props: CellEditorProps<unknown, number | null, CurrencyOptions>) {
const { ref, ...bindings } = useCellEditor(props, {
toDraft: (value) => currencyCellType.toText(value),
toValue: (draft) => currencyCellType.fromText(draft, props.options),
});
return <Input ref={ref} {...bindings} className="h-full w-full ..." />;
}
export const currencyCellType: CellType<unknown, number | null, CurrencyOptions> = {
Cell: CurrencyCell,
Editor: CurrencyEditor,
toText: (value) => (value == null ? "" : String(value)), // canonical, unformatted
toDisplayText: (value, options) => formatCurrencyDisplay(value, options), // display-only
fromText: (text) => {
const stripped = stripCurrencySymbols(text.trim());
if (stripped === "") return null;
const parsed = Number(stripped);
return Number.isNaN(parsed) ? null : parsed; // never throws — garbage -> null (clearValue's result)
},
clearValue: () => null,
isEmpty: (value) => value == null, // 0 is a real amount, not "empty"
compare: (a, b) => (a == null && b == null ? 0 : a == null ? -1 : b == null ? 1 : a - b),
align: "right",
};Register it through the cellTypes prop:
<DataGridProvider
{...grid}
columns={columns}
cellTypes={{ currency: currencyCellType }}
>The cellTypes prop merges over the built-ins: built-ins you do not name keep working, so
only your custom entries go in the object. To replace a built-in, give its key your type. To
remove a built-in (or one of your own entries), set its key to null. Columns that use a
removed key then fall back to the built-in text type and get the development warning.
An inline object literal for cellTypes is safe. The grid rebuilds its registry only when an
entry changes identity. Define each cell type once, outside the component, so its identity
stays stable across renders. Full semantics on Editing & cell types: Custom cell
types.
Testing your cell type
Mirror the shape of cell-types.test.tsx. Unit-test the value pipeline directly, with no
rendering needed for toText, fromText, isEmpty, and compare. Then add a small
render-based suite for the commit and cancel lifecycle of the editor.
import { describe, expect, it } from "vitest";
import { currencyCellType } from "./data-grid-custom-cell-demo";
describe("currencyCellType value pipeline", () => {
it("fromText strips currency symbols and thousands separators", () => {
expect(currencyCellType.fromText("$1,234.56")).toBeCloseTo(1234.56);
});
it("fromText never throws on garbage; returns clearValue()'s result (null)", () => {
expect(currencyCellType.fromText("not a price")).toBeNull();
expect(currencyCellType.fromText("")).toBeNull();
});
it("toText is the canonical unformatted number, not the display format", () => {
expect(currencyCellType.toText(1234.5)).toBe("1234.5");
});
it("isEmpty is null-only (0 is a real amount)", () => {
expect(currencyCellType.isEmpty(null)).toBe(true);
expect(currencyCellType.isEmpty(0)).toBe(false);
});
it("compare sorts nulls first", () => {
expect(currencyCellType.compare!(null, 1)).toBeLessThan(0);
expect(currencyCellType.compare!(1, 2)).toBeLessThan(0);
});
});See Editing & cell types for how editing activation and commit work across the grid. See Recipes for the minimal version of this same example, and API reference for the exact contract types.