DEV Community

Cover image for Building a Production-Grade Table Editor with React and XState
Keyur Paralkar
Keyur Paralkar

Posted on

Building a Production-Grade Table Editor with React and XState

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

Image description_

Notion

Image description_

Image description_

Google Sheets

Image description_

Image description_

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:

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: ""
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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 that cId1 appears before cId2.
    • rowOrder - This is exactly similar to that of the colOrder but 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 Column which defines the structure of the given column. A single Column can have the following properties: id name and style. Thus defining the characteristics of a column.

    • rowsById - Exactly similar to colsById but 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 by CellKey

      CellKey is represented by <row-id>:<col-id>. Every CellKey represents a Cell which defines the characteristics just as the above Column and Row types. 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

Image description_

Think of this schema like a normalized database:

  • rowOrder is the index
  • rowsById is the table
  • cells is 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:

  • init
  • ready

Conceptually, it looks like this:

Image description_

What Happens During Initialization?

  • It enters into the init state
  • 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 :

Image description_

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: {
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

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 init State Performs an Eventless Transition:

    init: {
                always: {
                    actions: ["initTable"],
                    target: "ready",
                },
            },
    
    • Here no event is required.
    • Execute the action initTable
    • On action complete move to ready state.
  • initTable action 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
    };
};

Enter fullscreen mode Exit fullscreen mode

Let me describe this hook briefly:

  • It takes input as actorRef which is a reference to our state machine.
  • Now to access the context that we had in our machine we use the XState’s useSelector hook 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: getColumnProperties getRowProperties and getCellKey getCellProperties and 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>
    );
};

Enter fullscreen mode Exit fullscreen mode

This is a pretty simple component and it does the following things:

  • Makes use of useMachine that takes our machine TableEditorMachine as the input. In this hook we also pass the arguments which acts as an input to our machine i.e. defaultColumns and defaultRows
  • We pass the machine’s reference actorRef to the our custom hook useGetTableProperties to 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.

Image description_

Hope you like my blog post.

You can follow me on twittergithub, and linkedIn.

Top comments (2)

Collapse
 
jpeggdev profile image
Jeff Pegg

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”.

Collapse
 
keyurparalkar profile image
Keyur Paralkar

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.