Skip to content

AG Grid Cell Editors from Inputs

This page describes how to turn a standard Arda input component (text, typeahead, select, etc.) into an AG Grid cell editor without forking the component. It generalizes the patterns established by TypeaheadInput and MultiSelectTypeaheadInput in ux-prototype.

The guiding principle, from the React Component Design page, is that the input is the editor. AG Grid’s cell editor contract (value + onValueChange) already matches a controlled React input. So we don’t build a separate editor — we add a thin adapter and a cellEditorMode styling branch.

A small factory wraps the input, maps AG Grid’s lifecycle onto the input’s controlled API, and registers the editor lifecycle hooks via useGridCellEditor.

import { useState, useRef, useCallback } from 'react';
import { useGridCellEditor } from 'ag-grid-react';
import { MyInput } from './my-input';
export interface MyCellEditorConfig {
// Whatever the input needs, fixed per column.
lookup?: (search: string) => Promise<Option[]>;
placeholder?: string;
}
export interface MyCellEditorProps {
value: TValue | null;
onValueChange: (value: TValue | null) => void;
stopEditing: (cancel?: boolean) => void;
}
function MyCellEditorInner({
value,
onValueChange,
stopEditing,
config,
}: MyCellEditorProps & { config: MyCellEditorConfig }) {
const [current, setCurrent] = useState(value ?? defaultValue);
const cancelledRef = useRef(false);
useGridCellEditor({
isCancelAfterEnd: () => cancelledRef.current,
});
const handleChange = useCallback(
(val: TValue) => {
setCurrent(val);
onValueChange(val);
},
[onValueChange],
);
// Commit-and-exit: the input calls this when the user is "done"
// (Enter / Tab / single-select pick). It maps to AG Grid's stopEditing.
const handleCommit = useCallback(() => stopEditing(), [stopEditing]);
return (
<MyInput
value={current}
onValueChange={handleChange}
onCommit={handleCommit}
cellEditorMode
className="w-full"
{...config}
/>
);
}
export function createMyCellEditor(config: MyCellEditorConfig) {
function CellEditor(props: MyCellEditorProps) {
return <MyCellEditorInner {...props} config={config} />;
}
CellEditor.displayName = `MyCellEditor(${config.placeholder ?? ''})`;
return CellEditor;
}

Usage in a column definition:

const RoleCellEditor = useMemo(
() => createMyCellEditor({ lookup: lookupRoles, placeholder: 'Select roles…' }),
[],
);
const columnDefs = [
{ field: 'roles', headerName: 'Roles', editable: true, cellEditor: RoleCellEditor },
];

The input keeps its normal form styling by default and switches to a grid-friendly presentation when cellEditorMode is set. This is the part with the non-obvious gotchas — see the contract below.

When cellEditorMode is true, the input must fit inside an AG Grid cell. AG Grid already draws the editing affordance (a border around the cell, themed with --ag-accent-color) and positions the editor over the cell. The input therefore has to defer to the cell rather than draw competing chrome.

ConcernForm modecellEditorModeWhy
Backgroundbg-backgroundbg-background (opaque)A transparent background lets the read-mode cell renderer bleed through behind the editor.
Border / radiusborder border-input rounded-mdborder-0 rounded-noneThe AG Grid cell supplies the edit border. A second inner border reads as a stray ring inside the cell.
Focus ringfocus-visible:ring-2focus-visible:ring-0 (and focus-within:ring-0 for token inputs)The cell border is the focus affordance; our ring would double it.
Heightnatural (h-9)h-fullFill the cell exactly so the hit area matches.
Vertical paddingpy-1py-0Combined with h-full, this centers content at the same vertical offset as the read renderer, avoiding a 1–2px jump on entering edit mode.
Dropdown / popupinlineportaled (Radix Popover)Cell containers clip overflow; a portal lets the dropdown escape.

A cell editor should be ready to type immediately. In cellEditorMode, focus the input on mount so the dropdown opens without an extra click:

React.useEffect(() => {
if (cellEditorMode) inputRef.current?.focus();
}, [cellEditorMode]);
  • onCommit — the input fires this when the user finishes (Enter, Tab, or a single-select pick). The adapter maps it to stopEditing().
  • Blur — accept the typed/selected value rather than reverting (a cell edit is a deliberate action). Reverting is the right default only in form mode.
  • Escape — handled by AG Grid to cancel; expose isCancelAfterEnd via useGridCellEditor if the input needs to veto a commit.

Prefer inline editing. An inline editor fills the cell and keeps the read and edit states visually identical (same height, same content offset).

Reach for cellEditorPopup: true only when the editor must grow beyond the cell bounds while editing — e.g. a multi-line memo, or a token input that expands to show every selected token. A popup floats with its own rounded corners and shadow, so it deliberately looks detached from the cell. Using it for a simple single-line input makes the editor look like a misaligned floating box.

Note that the dropdown of a typeahead does not require popup mode — the dropdown is already portaled via Radix Popover, so it escapes cell clipping on its own.

A recurring question is whether a visual issue belongs to the input or the grid. The rule of thumb:

  • The grid owns the cell box: the edit border, the cell background, vertical centering of read content, row height. Theme-level concerns (--ag-accent-color, row height) are configured once on the grid.
  • The input owns what’s inside the box: its own borders/rings (which it must drop in cellEditorMode), background opacity, content padding, and the dropdown.

If every cell editor shows the same treatment (e.g. the orange edit border), it is a grid theme concern. If only one editor misbehaves (bleed-through, an inner ring, a vertical shift), it is that input’s cellEditorMode styling.

When adding a cell editor to an input:

  • cellEditorMode prop added; default styling unchanged.
  • Opaque background; no inner border, radius, or focus ring in cell mode.
  • h-full py-0 so edit content aligns with the read renderer (no vertical jump).
  • Auto-focus on mount.
  • Dropdown/overlay portaled to escape cell overflow.
  • createXCellEditor factory with useGridCellEditor and onCommit → stopEditing.
  • Inline editing unless the editor must grow beyond the cell (cellEditorPopup).
  • Storybook InGrid story renders the canary DataGrid (not a raw AgGridReact).
  • Unit tests cover keyboard commit, blur-accept, and cancel; layout-dependent sizing is verified in the story / VRT (jsdom has no layout).

Copyright: (c) Arda Systems 2025-2026, All rights reserved