Back to Blog
By AriesZhou · · 11 min read

Collaborative Spreadsheet with react-data-grid (Part 2)

React

The previous article introduced the basic architecture of react-data-grid: EditorContainer handles editing state, and the Renderers pattern enables flexible rendering. This post records the related thinking and approach.

Why separate state and rendering

Reading code without a goal is inefficient. It’s generally better to first read some articles related to the code (such as the official blog or other bloggers’ analyses), then dive into the code with specific questions in mind. For example: why does react-data-grid separate state management from rendering logic?

Imagine a tightly coupled implementation:

function Cell({ row, col, isEditing, editorType, value, onChange }) {
  // 显示逻辑和编辑逻辑混在一起
  if (isEditing) {
    if (editorType === 'select') {
      return <select value={value} onChange={onChange}>...</select>;
    } else if (editorType === 'date') {
      return <DatePicker value={value} onChange={onChange} />;
    }
    // editorType 越多,if-else 越堆越长
  }
  return <span>{value}</span>;
}

The problems are obvious: Every time you add a new editor type, you have to modify the Cell component itself. The component has multiple responsibilities, making extension and reuse difficult.

react-data-grid’s solution is to let the Cell component only care about “when to show the editor”, not “what the editor looks like”.

interface CellRendererProps<TRow, TSummaryRow> {
  column: CalculatedColumn<TRow, TSummaryRow>;
  row: TRow;
  rowIdx: number;
  isCellActive: boolean;
  onRowChange: (row: TRow, commitChanges?: boolean) => void;
  // ...
}

As long as the editor follows this interface contract, it can be freely swapped. The Cell component doesn’t need to know which specific editor is being used.

This is the benefit of a well-designed interface: it defines the communication protocol between renderers and the DataGrid.

EditorContainer

EditorContainer is one of the most complex components in react-data-grid. Its core responsibilities are: Managing the lifecycle of editing state.

Detecting outside clicks

A common editor scenario: when the user clicks outside the editor, the edit should be committed automatically.

This feature looks simple, but implementing it is quite tricky:

// EditCell.tsx
function EditCell({ editor, onCommit, onCancel }) {
  useEffect(() => {
    function handleOutsideClick(event: MouseEvent) {
      // 检测点击是否发生在编辑器外部
      if (!editorRef.current?.contains(event.target as Node)) {
        onCommit();
      }
    }

    // 监听 capture 阶段,而非 bubble 阶段
    window.addEventListener('mousedown', handleOutsideClick, true);
    return () => window.removeEventListener('mousedown', handleOutsideClick, true);
  }, [onCommit]);
}

Why use the capture phase? The key interaction when closing the editor: when the user clicks outside the editor, the edit should be committed automatically.

Implementing this feature requires understanding the propagation order of DOM events. When the user clicks any element on the page, the event goes through two phases:

  1. Capture phase: the event propagates down from window to the target element

  2. Bubble phase: the event bubbles up from the target element back to window

If you listen during the bubble phase (the default), the event has already reached the target element. For example, if the user clicks an input outside the editor, that input has already gained focus. If you only then check whether the click was outside the editor, the timing becomes subtle: the editor has just decided to close, while another cell has already received focus.

If you listen during the capture phase, the event can be intercepted before it reaches the target. This means you can run the close-editor logic first, and only then let the new element gain focus. The focus-change timing is cleaner, with no overlapping intermediate states.

Another tricky part is managing the task lifecycle. Outside-click detection runs through commitOnOutsideMouseDown() this callback, but if the user’s editing session has already ended (for example, by pressing Escape to cancel), this pending task may still execute, causing an unwanted commit.

AbortController can solve this problem: when the editing state changes (the component re-renders), use abort() to cancel all previously scheduled tasks, ensuring that the old commit logic does not run in the new editing session.

// 使用 AbortController 管理生命周期
useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  if (canUsePostTask) {
    scheduler.postTask(() => commitOnOutsideMouseDown(), {
      priority: 'user-blocking',
      signal
    });
  } else {
    requestAnimationFrame(commitOnOutsideMouseDown);
  }

  return () => controller.abort();
}, []);

postTask is a newer browser API that lets you specify task priority. user-blocking priority ensures the editor-close logic runs fast enough, while AbortController can gracefully abort the task when the user cancels.

Active position normalization

Traditional editing-state management might look like this:

const [editingCell, setEditingCell] = useState<{ rowIdx: number; colIdx: number } | null>(null);
const [isEditing, setIsEditing] = useState(false);

But react-data-grid uses a more unified pattern: Active Position.

interface Position {
  rowIdx: number;
  colIdx: number;
}

interface ActivePosition extends Position {
  readonly mode: 'ACTIVE';  // 选中状态
}

interface EditPosition<R> extends Position {
  readonly mode: 'EDIT';    // 编辑状态
  readonly row: R;          // 正在编辑的行数据
  readonly originalRow: R;  // 编辑前的原始数据
}

Both states share the same position object, distinguished by the mode field. The benefits of this design are: State describes “where” rather than “what”.

Navigation logic doesn’t need to care whether the current state is editing or selecting. It only needs to know “where to go”:

function getNextActivePosition(position: Position, key: string): Position | null {
  switch (key) {
    case 'ArrowDown':
      return { ...position, rowIdx: position.rowIdx + 1 };
    case 'ArrowRight':
      return { ...position, colIdx: position.colIdx + 1 };
    // ...
  }
}

Switching edit state also becomes simpler. Just change mode from 'ACTIVE' to 'EDIT':

function startEdit(position: Position, row: TRow) {
  setActivePosition({
    ...position,
    mode: 'EDIT',
    row,
    originalRow: row
  });
}

function commitEdit() {
  setActivePosition({
    rowIdx: activePosition.rowIdx,
    colIdx: activePosition.colIdx,
    mode: 'ACTIVE'
  });
}

Editor Lifecycle Management

A complete editing session flow:

  1. Activate: the user clicks or presses a key to enter edit mode
  2. Edit: the user modifies data
  3. Commit/Cancel: the user clicks outside, presses Enter/Tab (commit), or presses Escape (cancel)
  4. Exit: return to the selected state

EditorContainer needs to handle many edge cases:

  • If row data is changed externally (for example, this record is updated elsewhere), it should exit editing automatically
  • If editing fails (for example, validation does not pass), it should stay in the editing state and show an error
  • After losing focus and regaining it, it should restore the editing state
// 当外部修改了正在编辑的行时,自动取消编辑
useEffect(() => {
  if (activePosition.mode === 'EDIT' && isRowModified) {
    onCancel();
  }
}, [rows, activePosition]);

Renderers mode

Interface design

It contains three levels of information:

Level 1: basic information

row: TRow;      // 行数据
column: CalculatedColumn<TRow, TSummaryRow>;  // 列配置
rowIdx: number; // 行索引

This is the minimum data needed for rendering.

Level 2: state information

isCellActive: boolean;  // 是否被选中
isDraggedOver: boolean; // 是否被拖拽覆盖

It lets the renderer adjust display styles based on state.

Level 3: action callbacks

onRowChange: (row: TRow, commitChanges?: boolean) => void;
setActivePosition: (position: Position) => void;

It lets the renderer trigger state changes without needing to know how state management is implemented.

The core principle of this design is: Let the component know “where” it is, but not “what happened”. CellRenderer doesn’t need to know why it was activated, only that “I am currently active,” and it can then choose a different rendering approach.

Controlled vs uncontrolled

Like form components, tables also have controlled and uncontrolled usage patterns.

// uncontrolled:组件自己管理状态
<DataGrid
  rows={data}
  columns={columns}
/>

// controlled:外部管理状态
<DataGrid
  rows={data}
  columns={columns}
  selectedRows={selectedRows}
  onSelectedRowsChange={setSelectedRows}
/>

react-data-grid determines the mode by checking whether props are passed in:

const isSelectedRowsControlled =
  selectedRows != null && onSelectedRowsChange != null;

const selectedRows = isSelectedRowsControlled
  ? selectedRows
  : internalSelectedRows;

The benefits of this pattern are: the component’s internal logic stays the same, only the data source differs. For users, you can switch between controlled and uncontrolled modes at any time without changing other code.

Default renderer chain

react-data-grid has an internal default renderer chain:

const renderCell = renderers?.renderCell
  ?? defaultRenderers?.renderCell
  ?? defaultRenderCell;

Three priority levels: user-defined renderer > default renderer > built-in renderer.

In most scenarios, the built-in default renderer is sufficient:

function defaultRenderCell({ row, column }) {
  const value = row[column.key];
  return <span>{value}</span>;
}

If you want custom formatting, you only need to provide renderCell:

const columns = [
  {
    key: 'amount',
    name: '金额',
    renderCell: ({ row }) => (
      <span>¥{row.amount.toLocaleString()}</span>
    )
  }
];

without overriding the entire cell logic. Principle of least knowledge is well reflected here: you only override the parts you want to customize, and everything else stays at its default.

The complete lifecycle of a single edit

Once you understand EditorContainer and Renderers, it becomes easier to grasp the logical interactions behind editing behavior.

sequenceDiagram
    participant User as 用户
    participant Cell as Cell 组件
    participant Container as EditorContainer
    participant Grid as DataGrid
    participant Store as 状态管理

    User->>Cell: 点击单元格
    Cell->>Grid: setActivePosition({ rowIdx, colIdx, mode: 'EDIT', row, originalRow })
    Grid->>Container: 渲染编辑器
    Container->>Container: 注册外部点击检测
    User->>Container: 输入内容
    User->>Cell: 点击外部
    Container->>Grid: onCellCommit({ rowIdx, colIdx, value })
    Grid->>Store: 更新 rows
    Store-->>Grid: 新 rows
    Grid->>Cell: 重新渲染

The key points are: State changes are unidirectional; renderers are only responsible for display.

User input → EditorContainer captures it → commits to DataGrid → DataGrid updates rows → Cell receives the new row data → renderCell re-renders

This chain is clear and predictable. Each link only focuses on its own responsibility, so there is no need to worry about state synchronization.

Evolution

When building an editor from scratch, the initial phase usually does not demand much in terms of editor types or format compatibility, and there is typically a noticeable iteration in complexity and maintainability.

For example, an MVP version may only need to work, and coupling is acceptable because the overall code complexity is relatively low. It might be written like this:

function Cell({ value, type, onChange, isEditing }) {
  if (isEditing) {
    if (type === 'text') {
      return <input value={value} onChange={(e) => onChange(e.target.value)} />;
    } else if (type === 'select') {
      return (
        <select value={value} onChange={(e) => onChange(e.target.value)}>
          <option value="a">A</option>
          <option value="b">B</option>
        </select>
      );
    } else if (type === 'date') {
      return <DatePicker value={value} onChange={onChange} />;
    }
    // 类型越多,if-else 越堆越长
  }
  return <span>{value}</span>;
}

The problem is obvious: the more types there are, the harder the code is to maintain. Adding a new type requires changing the Cell component itself, and handling styles for different states like display and editing also becomes cumbersome.

So what if we try separating the editor from the display by passing the editor in from outside:

function Cell({ value, editor: Editor, onChange, isEditing }) {
  if (isEditing && Editor) {
    return <Editor value={value} onChange={onChange} />;
  }
  return <span>{value}</span>;
}

That is a bit better, but the problem is that the Editor interfaces are not unified. Some use value props, while others use defaultValue, onChange with different parameter forms, so maintenance cost remains high.

At this point, it evolves into the renderers pattern, letting the column definition decide its own rendering logic:

const columns = [
  {
    key: 'name',
    name: '名称',
    renderCell: ({ row }) => <span>{row.name}</span>,
    renderEditCell: ({ row, onRowChange }) => (
      <input
        value={row.name}
        onChange={(e) => onRowChange({ ...row, name: e.target.value }, true)}
      />
    )
  },
  {
    key: 'status',
    name: '状态',
    renderCell: ({ row }) => <StatusBadge status={row.status} />,
    renderEditCell: ({ row, onRowChange }) => (
      <select
        value={row.status}
        onChange={(e) => onRowChange({ ...row, status: e.target.value }, true)}
      >
        <option value="pending">待处理</option>
        <option value="active">进行中</option>
        <option value="done">已完成</option>
      </select>
    )
  }
];
  • Each column type independently defines its own renderer
  • The editor and displayer share exactly the same interface (onRowChange)
  • Adding a new column type only requires adding one object, no need to modify the Cell component
  • Renderers are reusable; the same renderer can be used across different columns

🤔

Looking back at react-data-grid’s design, there are several points worth borrowing.

State normalization. EditorContainer unifies “where” and “what” into a single Position object, so navigation logic and editing logic are no longer coupled. This is cleaner than the traditional isEditing + editingCell approach.

Renderer chain. Automatic controlled/uncontrolled detection and fallback to a default renderer. These mechanisms make the component both flexible and controllable. Users can fully customize it, or configure nothing at all, and the component still works.

Unidirectional data flow. EditorContainer captures input, submits to DataGrid, DataGrid updates rows, renderers respond to changes. This chain is unidirectional, and each link only concerns itself with its own responsibility.

Interface design determines extensibility. The CellRendererProps design was carefully considered: required props, state-related props, and action callbacks. Three clearly separated layers. Once an interface is set, it is hard to change, so you need to think it through when designing one.

The tools carry over. Editor containers, renderer patterns, state normalization. These ideas are not limited to tables. Any complex form, or any scenario that requires separating state management from rendering, can borrow from them.

Once you understand why it was designed this way, using existing wheels or building your own becomes much more natural.

References