gridcn1.3.0

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

registry/default/blocks/data-grid/types.ts
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 }. The options prop carries the column's options, typed as TOptions when 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 through onChange, and tells the grid where the active cell goes next. cancel() discards the edit entirely. pending and rejectionCount matter only if the validate of 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 through fromText for every value that the type can hold. Quick search uses it when the type declares no toSearchText. 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 call toText directly, search reads toSearchText ?? toText, and filter runs on the raw value (see the value-pipeline note below). When you omit it, the type falls back to toText (see the displayText() helper, exported from the data-grid entry point). To render that text like a built-in cell, use CellSpan from the same entry point. It takes text and an optional align ("left", "right", or "center"), truncates the text, and applies the alignment.
  • fromText: parses pasted, imported, or typed text into TValue. 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 of clearValue(), not undefined and 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 of fromText, 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 this type. When you omit it, the default is a numeric-aware locale collation of the RAW String(value), not of toText output. Empty values (per isEmpty) sort last in both directions. compare only ever sees two non-empty values.
  • align: text alignment for the default Cell rendering. "right" reads better with tabular-nums for numeric-like types.
  • numeric: renders the cell's digits with tabular-nums, the fixed-width numerals that keep columns of numbers aligned. number and date declare it.
  • toSearchText: the quick-search text for a stored value. The default is toText. select declares it, so a select column 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 no validate of its own. Same two forms as the column's validate (a function or a Standard Schema. See Editing & cell types: Validation). Every validation path resolves column.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 an activate cell commit activate(value, options) directly, with no edit session (so onEditStart and onEditEnd never fire). checkbox declares (value) => !value. Without activate, the same gestures open the editor.
  • href: the link target for a stored value. The grid routes every result through safeHref (exported from the data-grid barrel) before it renders it or opens it: only http, https, and mailto survive, and a type cannot widen the list. url declares it; the openLink action (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.operators replaces the operator list the filter menu offers for the type's columns. filter.match is a raw-value predicate that replaces the built-in text matcher, with the same contract as the column's filterMatch. filter.valueInput picks 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. A filter.match or filterMatch predicate sees isNoneOf only when the column offers that operator. Add it to filter.operators or filterOperators for 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.

Text
Read-only
Validated
Checkbox
Select
Date
Currency
Link
Notes
renderCell
Silent Tiger
user0@example.com
63
Viewer
Jul 5, 2023
$12.50
javascript:alert(1)
First line Second line Third line
4
Quick Lion
user1@example.com
67
Viewer
Apr 3, 2021
$15.67
Short note
89
Swift Wolf
user2@example.com
40
Manager
Oct 27, 2020
$18.84
Short note
80
Eager Lion
user3@example.com
50
User
Oct 8, 2022
$22.01
Short note
51
Eager Tiger
user4@example.com
45
Viewer
Oct 7, 2024
$25.18
javascript:alert(1)
Short note
83
Silent Wolf
user5@example.com
24
User
Jun 1, 2020
$28.35
First line Second line Third line
15
Swift Lion
user6@example.com
34
Viewer
Oct 18, 2022
$31.52
Short note
62
Eager Lion
user7@example.com
21
Manager
Oct 7, 2021
$34.69
Short note
42

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 toSearchText when it declares one, and its toText otherwise. A column without a declared type searches the raw String(value). Filter matches the raw value, through the type's or column's filter.match when declared, else the built-in text matcher. So a custom toText changes what copy, export, and search produce, but NOT what filter matches. Consequences:
    • A select column searches the displayed LABEL (its toSearchText). Its filters match the stored value, so equals, isAnyOf, and isNoneOf take choice values, not labels.
    • A number column with decimals searches the rounded value.
    • A date column with a displayFormat matches the ISO string.
  • fromText must 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 of clearValue() out.
  • Store values as serializable primitives, not class instances: an ISO yyyy-mm-dd string, not a Date, and a plain number, not a Decimal or Money object. This makes compare a plain comparison, makes clipboard and JSON round-trips trivial, and avoids timezone drift entirely (a Date object carries a timezone, a string does not). The local-midnight parsing of the date type, using getFullYear(), getMonth(), and getDate(), never toISOString(), exists specifically to avoid a UTC day-shift when it converts a locally-parsed date back to text.
  • isEmpty semantics are deliberately per-type. Choose them consciously. For text, "" IS empty. For number, 0 is NOT empty (only null is). For checkbox, false is NOT empty (only null is). 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 treats 0 as empty, the fill handle skips real zero values.
  • The toDisplayText fallback chain (toDisplayText ?? toText, see the displayText() helper, exported from the data-grid entry point) keeps read-view formatting separate from the canonical text. Format for humans in toDisplayText, and keep toText a plain, parseable, round-trippable string. Clipboard and export call toText directly and never see toDisplayText at 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. The toText of select renders the option's label, what a spreadsheet user expects to copy. But fromText resolves 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 your isEmpty marks as empty sort last in both directions, for every type. compare never 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. Cell renders 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. The dateFormatterCache of the date type exists because constructing an Intl.DateTimeFormat measured ~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 options object literal is commonly re-created on every render. Writing options: { 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 like dateFormatterCache and the currencyFormatterCache of the worked example below.
  • Avoid allocations in the Cell render path beyond what is unavoidable. Every object literal, array, or closure created inside Cell is created again on every scroll-driven remount. Treat Cell like a shouldComponentUpdate-sensitive component in any virtualized list.
  • Keep formatting SSR-deterministic. Pin locales explicitly. Intl.DateTimeFormat and Intl.NumberFormat fall 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, 2026 against 5 Jul 2026). This produces different rendered text on the server and the client for identical column configuration, and React throws a hydration mismatch. The options.locale of the date type 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 explicit locale option still overrides it.

Editor UX

  • A text-input editor starts from useCellEditor, not from scratch. It owns the draft state (seeded from initialText, or from your codec's toDraft(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 the aria-label, aria-busy, and dir="auto" attributes. You supply one codec object (toDraft and toValue) 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 fires commit or onChange again on an edit that already committed. The guard is one useRef flag: tryCommit() returns true once, and false after that.
  • Honor pending and rejectionCount if your cell type can carry an async Standard Schema validate. CellEditorProps.pending is true while an async schema is resolving. Set your input readOnly, never disabled (a disabled input cannot receive Escape or blur, which silently blocks cancellation). rejectionCount increments on every rejected commit attempt (sync or async) and resets when a new edit session starts, not on every transition where pending clears for any reason (this also fires on Escape right before unmount). Re-arm your commit guard off rejectionCount, or a stray blur during teardown can fire a second, stale commit through the unguarded path. See the editors of text, number, select, and date for 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-in text and number editors 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 calls cancel() and must NOT call onChange at 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 initialText for type-to-replace. Grid activation and its defaults are on Edit activation. useSeedFocus has the pattern.
  • Popover and portal editors need data-grid-cell-editor on their portaled content, or an outside-click ends the edit instantly. Popover and Select content portals to document.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 checks event.target.closest("[data-grid-cell-editor]") and stops when it matches. Every popover editor (select, date) marks its PopoverContent or SelectContent with data-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. date and select commit on an explicit pick (a calendar day, an option). Closing the popover any other way (outside click, focus loss, Tab away) calls onOpenChange(false), which runs cancel() 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 Cell of checkbox renders shadcn's Checkbox with pointer-events-none and tabIndex={-1}, so it never receives a click. checkbox declares activate: (value) => !value, and the interaction layer of the grid commits that result through commitCellValue directly, so the control only reflects the resulting value prop. Any type can declare activate for the same in-place gesture. startEditing opens no session on a type that declares activate, so the Editor of checkbox does not mount through a grid gesture or action. CellType still requires an Editor. The Editor of checkbox mounts only for a session that code opens directly through the store (store.setState): it calls cancel() 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 GridCellTypes augmentation, so that defineColumns narrows options, 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.json aliases actually placed types.ts. Without this, type: "currency" on a column definition still works at runtime, but options and the value type of the column fall back to unknown at the type level. You lose the compile-time catches that defineColumns gives every built-in type, for a typo in an option key and for a typo in a type key.

  • Wrap your type in defineCellType once the augmentation is in. The helper defineCellType("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 declared GridCellTypes entry. 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 the cellTypes registry.

  • The cellTypes registry is untyped in value. Its type is Record<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 from defineColumns, defineCellType, and the augmentation above, not from the registry. A null entry removes a type from the registry. Consider a column.type string that resolves to no registry entry, for example a mistyped registry key. The cell then renders with the built-in text type, 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-checks accessorKey against TValue. Accessor/value cross-checking exists only on defineColumns' TypedColumnDef. A hand-written ColumnDef<Product, number | null> with accessorKey: "name" compiles; the value type is whatever you declare. Use defineColumns when your row type is known.

  • Read the typed options prop in cell components. The grid passes the column's options into Cell and Editor as the options prop, typed as TOptions when the type is parameterized with it. Parameterize your components with your options type (CellRenderProps<unknown, number | null, CurrencyOptions>) and destructure options directly. No cast is needed. The worked example below applies this to CurrencyOptions.

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.

Product
USD
EUR
Mechanical Keyboard
$129.99
119,50 €
4K Monitor
$449.00
415,00 €
USB-C Dock
$79.50
74,00 €
Webcam
$59.00

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.

registry/default/examples/data-grid-custom-cell-demo.tsx
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.

On this page