Introduction
In the previous blogpost, we build a table editor that used xstate’s state machine. We managed to build a schema driven architecture where schema was the source of truth for rendering things on the table-editor.
This blog post continues the same architecture and focuses on building a feature that will help to add a row and column into the table.
Addition of Row/Column
Our table editor in the current state, is just a basic table that loads some defaults. Now let us try to make it interactive by introducing addition of row and column.
The experience we are targeting should be such that it requires minimal effort for the user to add a row or a column into the table. I really liked notion’s UX of addition row and column:
Based on the similar lines we will also be building a similar UX for our table-editor. I have already implemented it and here is how it looks:
Here are the things we need to achieve this:
- Change in state machine for addition of row/column
- Handler UI around the right and bottom of the table editor.
💡
If you are a first time reader, I would highly recommend to please go through the first blogpost of this series that talks about how the xstate machine & the schema.
Let us first start by understand the things we need to change in state machine.
State Machine changes
Conceptually, adding a row/column can be reduced to four deterministic steps:
User clicks the row/column handlebar → Send Row/Column addition event to machine → Machine Updates Schema → UI renders new schema
These four steps are all it takes to build this feature. Let’s dive deeper into each of these steps.
User clicks the row/column handlerbar
This is basically building the UI for the user to interact with. We will look at in depth in the next section.
Send Row/Column addition event to machine & Schema update
My thought process here is to introduce entities to table. Entities are nothing but objects that impact the table editor’s state. There can be multiple types of entities:
- Table Entities: They deal with structural changes to the editor's state like add, removing, resizing, etc of rows & columns
- Cell entities: They deal with the structural changes related to cell
In case of this feature we are trying to add an entity i.e. row/column. So I introduced a new event named: add.table.entities. This event will be watched when you are in the ready state of the machine.
So now our state machine’s states will look:
state: {
init: {
always: {
actions: ["initTable"],
target: "ready",
},
},
ready: {
on: {
"add.table.entities": {
target: "addingTableEntities",
},
},
},
}
When the user interacts with the handlebars they will send the machine these events in the following manner:
const handleAddColumn = () => {
send({
type: "add.table.entities",
payload: {
type: "col",
},
});
};
const handleAddRow = () => {
send({
type: "add.table.entities",
payload: {
type: "row",
},
});
};
While sending the event we tell what type of entity we are trying to add here it is row / col.
When the machine receives this event, it transitions to addingTableEntities. This is another state that will have the following responsibilities:
- Deciding the the type of entity to add.
- Add the entity
- Return back to the ready state.
{
/**
* 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: {
on: {
"add.table.entities": {
target: "addingTableEntities",
},
},
},
/**
* Entities are objects that impact the table editor's state.
* There are multiple types of entities:
* - Table entities - They deal with structural changes to the editor's state like add, removing, resizing, etc rows & cols.
* - Cell entities - They deal with the structural changes related to cell
*/
addingTableEntities: {
initial: "decideOp",
states: {
decideOp: {
always: [
{
guard: "isAddingRow",
target: "addingRow",
},
{
target: "addingCol",
},
],
},
addingRow: {
always: {
actions: ["addRow"],
target: "complete",
},
},
addingCol: {
always: {
actions: ["addCol"],
target: "complete",
},
},
complete: {
type: "final",
},
},
onDone: {
target: "#table-editor-machine.ready",
},
},
}
addingTableEntities is a compound state which means having child states. Let me explain to you what the addingTableEntities state is doing:
- When this state becomes active from the
add.table.entitiesevent, it imimediately gets into thedecideOpinitial state. - When in
decideOp, we determine if its a row addition operation by checking the guard:
guards: {
isAddingRow: ({ event }) => event.payload.type === "row",
},
If true, it transitions to addingRow state else fallbacks to the addingCol state.
- Both
addingRow/addingColare exactly the same eventless transition states. They both point to the same target state:complete. They just differ by actions:addRow/addCol. Both of these actions only do the following thing: Update the schema by appending a new row/column into the rowOrder/colOrder and their property configs rowsById/colsById:
addRow: assign({
schema: ({ context }) => {
const { schema } = context;
return produce(schema, (draftSchema) => {
const len = draftSchema.rowOrder.length;
draftSchema.rowOrder.push(String(len));
draftSchema.rowsById[len] = {
id: String(len),
style: {
height: 20,
},
};
draftSchema?.rowOrder?.forEach((rO) => {
/**
* Use the colOrder from original context rather than draftContext
* because rowOrder is the one that is getting changed and colOrder remains unchanged.
*/
schema?.colOrder?.forEach((cO) => {
const cellKey =
`${rO}:${cO}` as keyof MachineContext["schema"]["cells"];
draftSchema.cells[cellKey] = {
kind: "empty",
value: "",
};
});
});
});
},
}),
addCol: assign({
schema: ({ context }) => {
const { schema } = context;
return produce(schema, (draftSchema) => {
const len = draftSchema.colOrder.length;
draftSchema.colOrder.push(String(len));
draftSchema.colsById[len] = {
id: String(len),
name: "",
style: {
width: 100,
},
};
schema?.rowOrder?.forEach((rO) => {
/**
* Use the rowOrder from original context rather than draftContext
* because colOrder is the one that is getting changed and colOrder remains unchanged.
*/
draftSchema?.colOrder?.forEach((cO) => {
const cellKey =
`${rO}:${cO}` as keyof MachineContext["schema"]["cells"];
draftSchema.cells[cellKey] = {
kind: "empty",
value: "",
};
});
});
});
},
}),
- The last state is the
completestate with thetype: :"final". This tells the machine that the operation has completed and no further transitions should occur within this branch. Once this state is reached we transition to the parent state from the child state i.e.completestate →addingTableEntitiesstate. - Now our aim is to get back to the
readystate when reach thecompletestate. Sincecompletestate is oftype: :"final", and when it is reached it fires theonDonestate done event.
onDone: {
target: "#table-editor-machine.ready",
},
In this transition we tell to redirect us to the ready state of the machine.
Once this transition is done the schema gets updated and the table-editor component picks up the new values and renders the same.
You might wonder — why not directly handle add.table.entities with an action inside ready? Because I want structural mutations to be explicit states, not invisible side-effects.
By introducing addingTableEntities as a compound state:
- Structural transitions become traceable
- Future logic can hook into this state
- The machine remains extensible
Now let us look at those UI handlebars that we were talking about in the earlier section.
Handler-bars UI around the table editor
The last piece of the code, is to create the UI that facilitates this addition of row and column. At the start of the blog we decided to make use of the notion’s user experience of having handle bars to the right and bottom of the table editor. We will be doing the same thing.
This piece of code add these handles to the main table-editor component hence here is the updated table-editor component:
const TableEditor = ({ defaultColumns, defaultRows }: TableEditorProps) => {
const [_, send, actorRef] = useMachine(TableEditorMachine, {
input: {
defaultColumns,
defaultRows,
},
});
const {
rowOrder,
colOrder,
getCellKey,
getCellProperties,
getColumnProperties,
getRowProperties,
} = useGetTableProperties({
actorRef,
});
const handleAddColumn = () => {
send({
type: "add.table.entities",
payload: {
type: "col",
},
});
};
const handleAddRow = () => {
send({
type: "add.table.entities",
payload: {
type: "row",
},
});
};
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>
{/** Handle bars for add rows/columns */}
<div
id="table-handlebar-col"
className="transition-all opacity-0 hover:opacity-100 hover:bg-gray-300/30 rounded h-full w-5 absolute top-0 left-full ml-2 flex flex-col justify-center items-center cursor-pointer"
onClick={handleAddColumn}
>
<span className="text-gray-600 select-none">+</span>
</div>
<div
id="table-handlebar-row"
className="transition-all opacity-0 hover:opacity-100 hover:bg-gray-300/30 rounded h-6 w-full absolute left-0 mt-2 flex justify-center items-center cursor-pointer"
onClick={handleAddRow}
>
<span className="text-gray-600 select-none">+</span>
</div>
</div>
);
};
Here is how our new handlebars will look like:
In the next article, we’ll tackle one of the hardest problems in table editors: selection.
And this is where things start to get interesting.
Hope you like my blog post.


Top comments (0)