Skip to content

Hierarchical Combobox

An autocomplete tag editor demonstrating hierarchical state management for complex UI interactions. The combobox tracks whether it’s open or closed, what’s highlighted, and what’s selected — and shows how nesting simplifies the handling of shared keyboard events across sub-states.

ActivefocusblurInactiveEmptySuggesting
State: Inactive
import { storeApi as createStoreApi, defineStates, effect, eventApi, matchina, setup } from "matchina";
import { nestedHsmRoot, submachine } from "matchina/hsm";
import { createComboboxStore } from "../store";
export function createComboboxMachine() {
const store = createComboboxStore();
const storeApi = createStoreApi(store);
const rootMachine = matchina(
defineStates({
Inactive: undefined,
// Define the Child Machine
Active: submachine(
() =>
matchina(
defineStates({
Empty: undefined,
Suggesting: undefined,
}),
// Child machine transitions
{
Empty: {
type: "Suggesting",
select: "Empty",
},
Suggesting: {
select: "Empty",
type: "Suggesting",
},
},
"Empty"
)
),
}),
// Parent machine transitions
{
Inactive: { focus: "Active" },
Active: {
blur: "Inactive",
},
},
"Inactive"
);
const hsm = nestedHsmRoot(rootMachine);
// Effects coordinate machine transitions with store updates
setup(hsm)(
effect((ev) => {
// HSM emits 'child.change' pseudo-event for child state changes
// Use type assertion since it's a runtime HSM feature not in static types
(ev.match as any)({
focus: storeApi.clear,
blur: storeApi.clear,
"child.change": (data: { target: any; type: string; params: any[] }) => {
const target = data?.target;
if (target && typeof target.getChange === 'function') {
const change = target.getChange();
change.match({
type: storeApi.setInput,
select: storeApi.selectHighlighted,
}, false);
}
},
}, false);
})
);
const combobox = Object.assign(hsm, {
model: store,
...storeApi,
...eventApi(hsm),
select: () => {
storeApi.selectHighlighted();
hsm.send("select");
},
});
return combobox;
}
export type NestedComboboxMachine = ReturnType<typeof createComboboxMachine>;
focusblurtypeInactiveActive
State: Inactive
import { effect, eventApi, guard, setup, storeApi as createStoreApi } from "matchina";
import { createHSM } from "matchina/hsm";
import { createComboboxStore } from "../store";
export function createFlatComboboxMachine() {
const store = createComboboxStore();
const storeApi = createStoreApi(store);
const machine = createHSM({
initial: "Inactive",
states: {
Inactive: {
on: {
focus: "Active",
},
},
// Define the Child Machine
Active: {
initial: "Empty",
states: {
Empty: {
on: {
type: "Suggesting",
select: "Empty",
}
},
Suggesting: {
data: (text: string) => text,
on: {
select: "Empty",
},
},
},
// Child machine transitions
on: {
blur: "^Inactive",
type: "Suggesting",
},
},
},
} as const);
setup(machine)(
guard((ev) =>
ev.match({
type: () => true,
_: () => true,
})
),
effect((ev) =>
ev.match(
{
focus: storeApi.clear,
select: storeApi.selectHighlighted,
blur: storeApi.clear,
type: storeApi.setInput,
},
false
)
)
);
return Object.assign(machine, {
model: store,
...storeApi,
...eventApi(machine),
select: () => {
storeApi.selectHighlighted();
machine.send("select");
},
});
}
export type FlatComboboxMachine = ReturnType<typeof createFlatComboboxMachine>;

The combobox has an Open state with child states for different interaction modes (typing, navigating with arrow keys, etc.). In the nested variant, keyboard events like Escape and Enter are defined once on the Open parent and propagate to all children automatically.

In the flattened variant, every sub-state of Open must explicitly handle those shared events. The machine is longer but every transition is visible at a glance without tracing through a parent hierarchy.