Introduction
Table editors look simple.
Add rows. Edit cells. Resize columns.
But once you add undo/redo, reordering, grouping, and consistent UI feedback — the logic becomes chaotic.
In this series, we’ll build a production-grade table editor using a state machine approach with XState.
What
Have you ever used SaaS products that have a great table editing and viewing experience. Let me give you some examples:
Clay
Notion
Google Sheets
They are just easy to navigate, put your data and you are done. It just feels natural and provides consistent feedback on every operation we do in these tables.
So in this blogpost series we will be looking at how to create your own table editor which provides an experience like these products. Especially we will try to implement features like:
- Row and column addition and deletion
- Re-ordering of columns and rows
- Re-sizing of rows and columns
- Grouping Rows and columns
- Table editing experience.
- Undo - redo operations
- etc….
Why
This series is not about competing with existing table libraries. It’s about understanding how production-grade data editors are architected.
When you understand the underlying model — schema normalization, state orchestration, transactional updates — you can design better systems everywhere else.
Prerequisites
To understand this blogpost series you need some basic understanding of the following:
- XState:
- Typescript
- React
How
We won’t be building all these features in this blogpost itself. That would be foolish 😀. We will start by a simple setting up of the project + designing the basic architecture of our table-editor component.
Here are the things we need:
- A simple react vite project. You can follow the vite’s official guide to setup your first react project with typescript template here
- Install the xState library by following the instructions here.
Architecture
The architecture of our table-editor can be understood in the following parts:
- Schema
- State Machine
- Editor Component
The Schema
We don’t want a complex web of useStates to manage the actual state of our component. We want it to be clear, precise, reliable and robust. To achieve this we need a reliable architecture that will scale better with the new feature. We achieve this with the help of schema.
A schema is nothing but a big JS Object that acts as a single source of truth for the table editor. This would be the source of whatever you see and happens to the table editor. For example, if you try to add a column or row then first it will be updated in the schema → And then all the subscribed components would derive their states from the schema’s current state.
Why to choose this pattern:
- Represents the current state of the table.
- Provides better developer experience.
- Makes the table robust.
- Errors can be caught pretty easily
- Scalable
So let us start first by looking at the schema with actual values in it:
{
colOrder: [cId1, cId2],
rowOrder: [rId1, rId2],
colsById: {
cId1: {
id: "cId1",
name: "col 1",
style: {...}
},
cId2: {
id: "cId2",
name: "col 2",
style: {...}
}
},
rowsById: {
rId1: {
id: "rId1",
name: "row 1",
style: {...}
},
rId2: {
id: "rId2",
name: "row 2",
style: {...}
}
},
cells: {
"rId1:cId1": {
kind: "empty",
value: ""
},
"rId1:cId2": {
kind: "empty",
value: ""
},
"rId2:cId1": {
kind: "empty",
value: ""
},
"rId2:cId2": {
kind: "empty",
value: ""
},
}
}
Above represents a 2x2 table i.e. with 2 rows and 2 columns. The picture will be much clearer when we look at the typescript schema for the same:
import type { CSSProperties } from "react";
export type Row = {
id: string;
style?: Partial<CSSProperties>;
};
export type Column = {
id: string;
name: string;
style?: Partial<CSSProperties>;
};
export type RowId = string;
export type ColumnId = string;
export type CellKey = `${RowId}:${ColumnId}`;
type CellValue =
| {
kind: "text";
value: string;
}
| {
kind: "number";
value: number;
}
| {
kind: "empty";
value: "";
};
type CellMeta = {
style?: Omit<Partial<CSSProperties>, "width" | "height">;
};
type Cell = CellValue & CellMeta;
export type Schema = {
colOrder: Array<ColumnId>;
rowOrder: Array<RowId>;
colsById: Record<ColumnId, Column>;
rowsById: Record<RowId, Row>;
cells: Record<CellKey, Cell>;
};
A schema is represented above typescript types. Let me briefly describe these types for your:
-
Every schema is composed of the following properties:
-
colOrder- These are the orders that user sees on the UI for columns. From the above example, when this order is =[cId1, cId2]that means thatcId1appears beforecId2. -
rowOrder- This is exactly similar to that of thecolOrderbut for rows. -
colsById- This is a object where key is the column’s id and value is the properties that define that column. So this is an object which provides all the information about a column when you query it by id.Every value is of type
Columnwhich defines the structure of the given column. A singleColumncan have the following properties:idnameandstyle. Thus defining the characteristics of a column. rowsById- Exactly similar tocolsByIdbut for rows.-
cells- This is again an object which can be used to get the properties of each individual cell. If you want to get information about a cell you should query it byCellKeyCellKeyis represented by<row-id>:<col-id>. EveryCellKeyrepresents aCellwhich defines the characteristics just as the aboveColumnandRowtypes. Please have a look at them above in the TS types.
-
Now the question arises as to why we did we design schema with this approach?
- This schema is normalized.
- It separates structure from order.
- It prevents expensive array traversal.
- It scales to thousands of rows.
The Schema is designed in this way to provide maximum scaleability and maintainability with the help of separation of concerns. This is achieved by keeping the order related stuff that the user would see in the separate array i.e. rowOrder colOrder and information about each of this row and column in the rowsById and colsById.
Now changing the order would simply mean changing the position of row ids in the rowOrder array.
If you want to update the characteristics of a specific row then you can do so by changing the row’s value from the rowsById mapping.
Let me explain you this by an example:
-
Consider that the above schema would have been represented in the following manner:
Schema = { rows: Array<Row>; columns: Array<Column>; ... } -
If you wanted to change the position of a row, then you need to do the following things:
- First find the row.
- Take the row
- Put it at the correct place that you want
- re-index the id property of the entire array.
-
Now also consider that you wanted to change the background color of the row:
- Again we find the row
- Update the background property in the styles
But with the current i.e. the split approach:
- It simplifies updates
- It avoids unnecessary object recreation
- It enables partial updates
- It keeps reordering separate from mutation
Think of this schema like a normalized database:
-
rowOrderis the index -
rowsByIdis the table -
cellsis a join table between rows and columns
Now that we have a normalized data model, we need a way to orchestrate how it changes over time.
That’s where state machines come in.
State Machine
State machine is a model that will help you organise complex state logic that you have in your component, app etc. It does that by defining:
- States
- Events
- Transitions
- Side effects
Instead of scattering logic across multiple useState hooks, we centralize behavior inside a predictable model.
For our table editor, we’ll use Stately’s library XState to orchestrate the editor logic.
Machine Overview
The flow of our table editor is quite simple. There are two of states to it:
initready
Conceptually, it looks like this:
What Happens During Initialization?
- It enters into the
initstate - It generates the initial schema based on input (
defaultRows,defaultColumns) i.e. the defaults we pass to the machine - It transitions to
ready.
Here is a quick look at how our table will look like with this state machine when loaded with defaults: rows = 4 & cols = 3 :
The Machine Definition
Here is the actual XState state machine that wraps the above logical flow:
const TableEditorMachine = setup({
types: {
context: {} as MachineContext,
input: {} as MachineInput,
},
actions: {
initTable: assign({
schema: ({ context }) => {
const {
defaults: { columns, rows },
schema,
} = context;
return produce(schema, (draftSchema) => {
/**
* This is where the magic happens:
* - We create a rowOrder and ColumnOrder by filling it with ids. At the current moment they can be indices
* - Then we fill up the colsByID and rowsById objects
* - Then we fill up cells with empty states
*/
draftSchema.rowOrder = new Array(rows)
.fill(0)
.map((_, i) => String(i));
draftSchema.colOrder = new Array(columns)
.fill(0)
.map((_, i) => String(i));
draftSchema.rowsById = draftSchema.rowOrder.reduce(
(acc, curr) => {
acc[curr] = {
id: curr,
style: {
height: 20,
},
};
return acc;
},
{} as MachineContext["schema"]["rowsById"],
);
draftSchema.colsById = draftSchema.colOrder.reduce(
(acc, curr) => {
acc[curr] = {
id: curr,
name: "",
style: {
width: 100,
},
};
return acc;
},
{} as MachineContext["schema"]["colsById"],
);
/**
* We define cells as well:
*/
draftSchema?.rowOrder?.forEach((rO) => {
draftSchema?.colOrder?.forEach((cO) => {
const cellKey =
`${rO}:${cO}` as keyof MachineContext["schema"]["cells"];
draftSchema.cells[cellKey] = {
kind: "empty",
value: "",
};
});
});
});
},
}),
},
}).createMachine({
id: "table-editor-machine",
context: ({ input }) => ({
defaults: {
rows: input.defaultRows,
columns: input.defaultColumns,
},
schema: {
rowOrder: [],
colOrder: [],
colsById: {},
rowsById: {},
cells: {},
},
}),
initial: "init",
states: {
/**
* The task of this state is to initialize the table with default no. of rows and columns
* which is passed as an input to this machine
*/
init: {
always: {
actions: ["initTable"],
target: "ready",
},
},
ready: {
},
},
});
What this machine is doing
Let’s break this machine down clearly:
-
The machine accepts input:
context: ({ input }) => ({ defaults: { rows: input.defaultRows, columns: input.defaultColumns, }, ... })This ensures that the machine is initialized with the required inputs. These inputs will be used to generate the defaults in the schema.
-
The
initState Performs an Eventless Transition:
init: { always: { actions: ["initTable"], target: "ready", }, },- Here no event is required.
- Execute the action
initTable - On action complete move to
readystate.
-
initTableaction builds the entire schema with defaults by doing the following:- Creates ordered row IDs
- Creates ordered column IDs
- Populates
rowsById - Populates
colsById - Generates every cell key (
rowId:colId)
All mutations above are handled with immutably using
produce(Immer).
The important thing to note here is that: The schema is generated **inside the machine**, not inside React. This means that:
- Initialization logic is centralized.
- It is testable.
- It is deterministic.
- It is independent from rendering.
Why this matters and what it means?
Even though our machine is small, this structure gives us:
- A predictable lifecycle
- A single place for orchestration
- A scalable foundation for future features
When we add:
- Row insertion
- Column resizing
- Undo/redo
- Reordering
- Grouping
We won’t mutate React state directly. Rather we will send events to this machine. That’s the real power of this approach. The machine owns orchestration. React only renders.
React becomes a rendering layer. The machine becomes the source of truth.
Now that we know how the machine works and the importance of this approach, let us understand how can you hook this with your react code.
Table Editor
Before we start hooking the machine let us first create these building blocks to create a table: td th tr :
td:
import type { CSSProperties, PropsWithChildren } from "react";
type GridCellProps = PropsWithChildren & {
className?: string;
style?: CSSProperties;
};
const GridCell = ({ children, className, style }: GridCellProps) => {
return (
<td className={className} style={style}>
{children}
</td>
);
};
export default GridCell;
th:
import type { CSSProperties, PropsWithChildren } from "react";
type GridHeaderProps = PropsWithChildren & {
className?: string;
style?: CSSProperties;
};
const GridHeader = ({ children, className, ...rest }: GridHeaderProps) => {
return (
<th className={className} {...rest}>
{children}
</th>
);
};
export default GridHeader;
tr:
import type { CSSProperties, PropsWithChildren } from "react";
type GridRowProps = PropsWithChildren & {
className?: string;
style?: CSSProperties;
};
const GridRow = ({ children, className, style }: GridRowProps) => {
return (
<tr className={className} style={style}>
{children}
</tr>
);
};
export default GridRow;
You can even use any component library you want, just make sure to pass the props correctly.
Here is a custom hook that will help us to get table properties from our machine:
type UseGetTablePropertiesProps = {
actorRef: ActorRefFrom<typeof TableEditorMachine>;
};
/**
* A hook that provides helper functions for extracting properties from entities like col, row and cells
*/
export const useGetTableProperties = ({
actorRef,
}: UseGetTablePropertiesProps) => {
const { colOrder, colsById, rowOrder, rowsById, cells } = useSelector(
actorRef,
(s) => s.context.schema,
);
const getColumnProperties = (colId: ColumnId) => {
const columnProps = colsById[colId];
const { id, name, style } = columnProps;
return {
id,
key: `col-${id}`,
"data-col-name": name,
style,
};
};
const getRowProperties = (rowId: RowId) => {
const rowProps = rowsById[rowId];
const { id, style } = rowProps;
return {
id,
key: `row-${id}`,
style,
};
};
const getCellKey = (rowId: RowId, colId: ColumnId): CellKey =>
`${rowId}:${colId}`;
const getCellProperties = (cellKey: CellKey) => {
const cellProps = cells[cellKey];
const { kind, value, style } = cellProps;
return {
id: cellKey,
key: cellKey,
kind,
value,
style,
};
};
return {
colOrder,
colsById,
rowOrder,
rowsById,
cells,
getCellKey,
getCellProperties,
getColumnProperties,
getRowProperties,
};
};
Let me describe this hook briefly:
- It takes input as
actorRefwhich is a reference to our state machine. - Now to access the context that we had in our machine we use the XState’s
useSelectorhook to get all the properties we need. You can get all the information you need from the machine. You just need to tell the selector that you need information via this piece of code:(s) => s.context.schema. Since this is the information that we need i.e. source of truth to render the UI. - This hook exposes other helper functions:
getColumnPropertiesgetRowPropertiesandgetCellKeygetCellPropertiesand other variables. The functions here provides you the meta data about a row, column or a cell via providing them row, col and cell id. This makes it easier in the rendering part to render the UI elements.
useSelector ensures that the component only re-renders when the selected part of the machine changes.
Now let us create a table-editor component that will hook all of the above things:
const TableEditor = ({ defaultColumns, defaultRows }: TableEditorProps) => {
const [_, send, actorRef] = useMachine(TableEditorMachine, {
input: {
defaultColumns,
defaultRows,
},
});
const {
rowOrder,
colOrder,
getCellKey,
getCellProperties,
getColumnProperties,
getRowProperties,
} = useGetTableProperties({
actorRef,
});
return (
<div className="inline-block relative">
<table>
<thead>
<GridRow>
{colOrder?.map((c) => {
const { key, ...rest } = getColumnProperties(c);
return (
<GridHeader key={key} className="border" {...rest}>
{c}
</GridHeader>
);
})}
</GridRow>
</thead>
<tbody>
{rowOrder?.map((r) => {
const { key, ...rest } = getRowProperties(r);
return (
<GridRow key={r} {...rest}>
{colOrder?.map((c) => {
const cellKey = getCellKey(r, c);
const { key, value, ...rest } = getCellProperties(cellKey);
return (
<GridCell key={key} className="border" {...rest}>
{value}
</GridCell>
);
})}
</GridRow>
);
})}
</tbody>
</table>
</div>
);
};
This is a pretty simple component and it does the following things:
- Makes use of
useMachinethat takes our machineTableEditorMachineas the input. In this hook we also pass the arguments which acts as an input to our machine i.e.defaultColumnsanddefaultRows - We pass the machine’s reference
actorRefto the our custom hookuseGetTablePropertiesto get the table properties. - Lastly we loop over the row and column order and use the building blocks that we created to render each table property.
In the next article, we’ll implement row and column insertion — not as ad-hoc mutations, but as state machine transitions.
Hope you like my blog post.









Top comments (2)
I can tell just by the errors in the graphic for this article that this is vibe-coded slop and not going to be “production grade”.
Thanks for the feedback @jpeggdev . If you noticed specific issues in the graphic, I’d appreciate details — happy to improve it.
The article focuses on the architecture and state management patterns — would love your thoughts on those.