Collaborative Tables with react-data-grid (Part 1)
react-data-grid is an open-source React table library from Comcast, used by many enterprises in production. Its design approach is helpful for understanding collaborative tables.
Common interaction issues with table components
Table components, especially collaborative tables, involve complex scenarios, interactions, and edge cases. Here is a brief list of common scenarios and issues at the user interaction level:
- Editors
Complex types: for example, plain text, long text (rich text), numbers, dates, times, single select, multi-select, links, etc.
Edit state switching: click interactions, hover interactions, focus/blur interactions, etc.
Positioning: in-cell editing (no positioning concerns here), popup-layer editing (requires considering where to pop up and edge cases)
Validation: input type restrictions, masking, etc. Different editors have their own validation rules.
- State management
Edit state: which cell is currently being edited?
Selection state: which cell or cells are selected? Is it shift multi-select or drag-to-select?
Row and column changes: add/delete/reorder/drag/filter/group, etc.
Undo/redo: keyboard shortcuts, history, validity period, etc.
- User interaction
Keyboard navigation: arrow keys to switch selected cells, tab navigation, and other a11y issues
Keyboard shortcuts: intuitive copy, paste, select all, undo, etc.
Drag and drop: fill-down, rule definition, column resize, row dragging, etc.
Multi-select: shift-click range selection, control/command-click individual selection, mouse drag box selection, etc.
- Performance issues
Data loading: how to load large amounts of data?
Re-rendering: how to avoid refreshing the entire table when edit state changes?
Virtual scrolling: render only rows/columns in the visible area and buffer zone
For collaborative tables, you also need to consider data synchronization and how to update data and state in a user-friendly way in the UI. This is a fairly complex topic. For now, let’s set aside the data sync scenario and study the design and implementation of react-data-grid purely from the UI side.
react-data-grid basic architecture
Design philosophy
react-data-grid’s design revolves around several core principles:
- Configuration-driven: all behavior is configured through column definitions
- Renderer pattern: custom components handle rendering and editing
- Unified state management: edit state is managed centrally by DataGrid
- Virtualization: high-performance rendering of large datasets
Component hierarchy
flowchart TD
A[DataGrid] --> B[HeaderRow]
A --> C[Rows]
A --> D[EditorContainer]
A --> E[SummaryRow]
C --> F[Row]
F --> G[Cell]
G --> H[CellRenderer]
D --> I[Editor]
I --> J[EditorContainer]
Data flow
flowchart LR
A[用户交互] --> B[DataGrid]
B --> C[更新 state]
C --> D[触发重渲染]
D --> E[渲染 Cell/Editor]
E --> F[Editor 回调]
F --> B
Core pattern: Renderers
Renderer is the core concept of react-data-grid: Use custom components to handle cell rendering and editing.
const columns = [
{
key: 'name',
name: '姓名',
renderer: <CustomCellRenderer />,
editor: <CustomEditor />,
}
];
This approach hands “how to display” and “how to edit” entirely to the developer, while the library itself only provides the framework.
Categories
| Type | Purpose | Interface |
|---|---|---|
| cellRenderer | Cell rendering | { row, column, isCellSelected } |
| headerRenderer | Header rendering | { column, sortDirection } |
| rowRenderer | Row rendering | { row, renderers } |
| summaryRowRenderer | Summary row rendering | { row, isCellSelected } |
Demo
// 自定义单元格渲染器
const CustomCellRenderer = ({ row, column, isCellSelected }) => {
const value = row[column.key];
return (
<div
className={`cell ${isCellSelected ? 'selected' : ''}`}
style={{ backgroundColor: isCellSelected ? '#e3f2fd' : 'transparent' }}
>
{value}
</div>
);
};
Benefits:
- High flexibility: any React component can serve as a renderer
- Reusable: the same renderer can be used across different columns
- Separation of concerns: display logic is decoupled from table logic
Risks:
- Excessive flexibility can lead to inconsistency
Editors
EditorContainer
react-data-grid uses EditorContainer to manage editing state uniformly:
class EditorContainer extends React.Component {
constructor(props) {
super(props);
this.state = {
editing: null, // { rowIdx, colIdx }
value: null
};
}
startEdit = ({ rowIdx, colIdx }) => {
this.setState({
editing: { rowIdx, colIdx },
value: this.getCellValue(rowIdx, colIdx)
});
}
commitEdit = () => {
const { rowIdx, colIdx } = this.state.editing;
const { onCellCommit } = this.props;
onCellCommit({
rowIdx,
colIdx,
value: this.state.value
});
this.setState({ editing: null });
}
}
Interface
react-data-grid defines a standard interface for editors:
interface CellEditor {
// 获取编辑器当前值
getValue(): any;
// 获取原始值(用于取消编辑)
getOldValue(): any;
// 验证编辑器值
validate(): boolean;
// 可选:焦点处理
focus?(): void;
// 可选:获取 props
getProps?(): any;
}
Built-in editors
react-data-grid provides several built-in editors:
| Editor | Purpose |
|---|---|
| AutoComplete | Autocomplete |
| DropDownEditor | Dropdown selection |
| DateEditor | Date selection |
Custom editors
class TextEditor extends React.Component {
getValue() {
return {
[this.props.column.key]: this.input.value
};
}
getOldValue() {
return {
[this.props.column.key]: this.props.value
};
}
validate() {
return this.input.value.length > 0;
}
render() {
return (
<input
ref={(node) => this.input = node}
defaultValue={this.props.value}
onBlur={() => this.props.onCommit(this.getValue())}
/>
);
}
}
Challenges with editors
| Challenge | Solution |
|---|---|
| Editor and cell positioning | Absolute positioning, z-index management |
| Editor lifecycle | onFocus, onBlur, onCommit |
| Validation failure handling | Show error state, block commit |
| Keyboard event handling | Arrow keys, Tab, Enter, Escape |
State management
class DataGrid extends React.Component {
state = {
rows: [], // 数据
selected: null, // 选中状态 { rowIdx, colIdx }
editing: null, // 编辑状态 { rowIdx, colIdx }
sortColumns: [], // 排序列
filters: {}, // 过滤条件
columnWidths: {}, // 列宽
};
}
State update patterns
// 统一的状态更新入口
updateState = (updates) => {
this.setState(updates, () => {
// 状态更新后的回调
this.persistState();
});
};
Edit state propagation
// DataGrid -> Cell -> Editor
<DataGrid>
<Cell
isEditing={this.state.editing?.rowIdx === rowIdx && this.state.editing?.colIdx === colIdx}
onEdit={this.startEdit}
/>
</DataGrid>
The design of edit state management needs to consider the following points:
- Conflict between editing and selection states: can other cells still be selected while editing is in progress, and how should clicking another cell during editing behave?
- When supporting batch editing, how to handle copying and pasting multiple rows of data, how to handle mismatched data formats, and how to handle cases where more cells are copied than the target cells.
- Undo functionality: how to design the history stack.
Keyboard navigation
handleKeyDown = (e) => {
const { selected, editing } = this.state;
if (editing) {
// 编辑状态下的键盘处理
if (e.key === 'Escape') {
this.cancelEdit();
} else if (e.key === 'Enter') {
this.commitEdit();
} else if (e.key === 'Tab') {
e.preventDefault();
this.moveToNextCell(e.shiftKey);
}
} else {
// 非编辑状态的键盘处理
if (e.key === 'ArrowDown') {
this.moveSelection(1, 0);
}
// ...
}
};
Common issues with Tab navigation
- Circular navigation: return to the first column after reaching the last column?
- Across rows: after finishing editing one row, should Tab automatically jump to the next row?
- Skipping non-editable columns: how to configure which columns are editable?
- Committing while editing: on Tab, should changes be committed automatically or cancelled?
Virtual scrolling
When the data volume reaches tens of thousands of rows, rendering all DOM nodes at once causes severe performance issues, so virtual scrolling is necessary.
Virtualization in react-data-grid
// 简化版虚拟滚动
class VirtualRows extends React.Component {
render() {
const { rows, scrollTop, rowHeight } = this.props;
const startIdx = Math.floor(scrollTop / rowHeight);
const endIdx = startIdx + VISIBLE_COUNT;
const visibleRows = rows.slice(startIdx, endIdx);
return (
<div style={{ height: rows.length * rowHeight }}>
<div style={{ transform: `translateY(${startIdx * rowHeight}px)` }}>
{visibleRows.map((row, i) => (
<Row
key={row.id}
row={row}
index={startIdx + i}
/>
))}
</div>
</div>
);
}
}
Challenges of virtualization
| Challenge | Description |
|---|---|
| Uncertain row height | How to handle dynamic row heights? |
| Scroll position synchronization | How to synchronize virtual scrolling with actual scrolling? |
| Sticky columns | How to keep fixed columns in place during virtual scrolling? |
| Performance | How to avoid frequent calculations while scrolling? |
Collaboration
flowchart LR
A[用户 A] --> B[本地状态]
C[用户 B] --> D[本地状态]
B --> E[协同服务器]
D --> E
E --> F[CRDT 算法]
F --> G[冲突解决]
G --> H[同步状态]
- CRDT: Conflict-free Replicated Data Types for resolving concurrent edits
- OT: Operational Transformation, another mainstream approach
- WebSocket: Real-time communication