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.
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>;import { useRef, useEffect } from "react";import { setup, effect } from "matchina";import { useMachine } from "matchina/react";import { ComboboxView } from "../../combobox-common/ComboboxView";import type { NestedComboboxMachine } from "./machine";
interface ComboboxViewProps { machine: NestedComboboxMachine;}
export function ComboboxViewNested({ machine }: ComboboxViewProps) { useMachine(machine); useMachine(machine.model); const { input, selectedTags, suggestions, highlightedIndex } = machine.model.getState(); const searchRef = useRef<HTMLInputElement>(null);
const state = machine.getState(); const isActive = state.key !== "Inactive"; useEffect(() => setup(machine)( effect((ev: any) => { if (ev.type === "focus") searchRef.current?.focus(); if (ev.type === "blur") searchRef.current?.blur(); }) ), [machine] );
return ( <ComboboxView stateKey={state.key} isActive={isActive} input={input} selectedTags={selectedTags} suggestions={suggestions} highlightedIndex={highlightedIndex} searchRef={searchRef} onFocus={() => machine.send("focus")} onBlur={() => machine.send("blur")} onType={(v) => machine.send("type", v)} onSelect={(i) => { machine.setHighlighted(i); machine.select(); }} onAdd={(tag) => machine.addTag(tag)} onRemove={(tag) => machine.removeTag(tag)} onHighlight={(dir) => machine.highlight(dir)} /> );}import { useMemo, useState } from "react";import { createFlatComboboxMachine } from "./flattened/machine";import { createComboboxMachine } from "./nested/machine";import { ComboboxViewNested } from "./nested/ComboboxView";import { ComboboxViewFlat } from "./flattened/ComboboxView";import { MachineVisualizer } from "@components/MachineVisualizer";
type Mode = "flat" | "nested";
export default function HSMComboboxIndex() { const [mode, setMode] = useState<Mode>("flat");
// Re-create machine when mode changes const { flatMachine, hierarchicalMachine } = useMemo(() => { return { flatMachine: createFlatComboboxMachine(), hierarchicalMachine: createComboboxMachine(), }; }, [mode]);
const machine = mode === "flat" ? flatMachine : hierarchicalMachine;
return ( <div className="space-y-6"> {/* Mode Toggle - Sticky below header */} <div className="flex justify-center mb-6 sticky top-16 z-10 py-2 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm -mx-4 px-4"> <div className="inline-flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg"> <button onClick={() => setMode("flat")} className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${ mode === "flat" ? "bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm" : "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" }`} > Flattened </button> <button onClick={() => setMode("nested")} className={`px-4 py-2 rounded text-sm font-medium transition-colors ${ mode === "nested" ? "bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm" : "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" }`} > Nested (Hierarchical) </button> </div> </div>
{mode === "flat" ? ( <ComboboxViewFlat machine={flatMachine} /> ) : ( <ComboboxViewNested machine={hierarchicalMachine} /> )}
<MachineVisualizer key={mode} // Force re-mount of visualizer when mode changes machine={machine} title={`HSM Combobox (${mode === "flat" ? "Flattened" : "Nested"})`} interactive={true} layout="stacked" /> </div> );}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>;import { useRef, useEffect } from "react";import { effect, setup } from "matchina";import { useMachine } from "matchina/react";import { ComboboxView } from "../../combobox-common/ComboboxView";import type { FlatComboboxMachine } from "./machine";
interface ComboboxViewFlatProps { machine: FlatComboboxMachine;}
export function ComboboxViewFlat({ machine }: ComboboxViewFlatProps) { useMachine(machine); useMachine(machine.model); const { input, selectedTags, suggestions, highlightedIndex } = machine.model.getState(); const searchRef = useRef<HTMLInputElement>(null);
const state = machine.getState(); const isActive = state.key !== "Inactive"; useEffect(() => setup(machine)( effect((ev: any) => { if (ev.type === "focus") searchRef.current?.focus(); if (ev.type === "blur") searchRef.current?.blur(); }) ), [machine] );
return ( <ComboboxView stateKey={state.key} isActive={isActive} input={input} selectedTags={selectedTags} suggestions={suggestions} highlightedIndex={highlightedIndex} searchRef={searchRef} onFocus={() => machine.send("focus")} onBlur={() => machine.send("blur")} onType={(v) => machine.send("type", v)} onSelect={(i) => { machine.setHighlighted(i); machine.select(); }} onAdd={(tag) => machine.addTag(tag)} onRemove={(tag) => machine.removeTag(tag)} onHighlight={(dir) => machine.highlight(dir)} /> );}import { useMemo, useState } from "react";import { createFlatComboboxMachine } from "./flattened/machine";import { createComboboxMachine } from "./nested/machine";import { ComboboxViewNested } from "./nested/ComboboxView";import { ComboboxViewFlat } from "./flattened/ComboboxView";import { MachineVisualizer } from "@components/MachineVisualizer";
type Mode = "flat" | "nested";
export default function HSMComboboxIndex() { const [mode, setMode] = useState<Mode>("flat");
// Re-create machine when mode changes const { flatMachine, hierarchicalMachine } = useMemo(() => { return { flatMachine: createFlatComboboxMachine(), hierarchicalMachine: createComboboxMachine(), }; }, [mode]);
const machine = mode === "flat" ? flatMachine : hierarchicalMachine;
return ( <div className="space-y-6"> {/* Mode Toggle - Sticky below header */} <div className="flex justify-center mb-6 sticky top-16 z-10 py-2 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm -mx-4 px-4"> <div className="inline-flex bg-gray-100 dark:bg-gray-800 p-1 rounded-lg"> <button onClick={() => setMode("flat")} className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${ mode === "flat" ? "bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm" : "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" }`} > Flattened </button> <button onClick={() => setMode("nested")} className={`px-4 py-2 rounded text-sm font-medium transition-colors ${ mode === "nested" ? "bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm" : "text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" }`} > Nested (Hierarchical) </button> </div> </div>
{mode === "flat" ? ( <ComboboxViewFlat machine={flatMachine} /> ) : ( <ComboboxViewNested machine={hierarchicalMachine} /> )}
<MachineVisualizer key={mode} // Force re-mount of visualizer when mode changes machine={machine} title={`HSM Combobox (${mode === "flat" ? "Flattened" : "Nested"})`} interactive={true} layout="stacked" /> </div> );}Nested vs flattened
Section titled “Nested vs flattened”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.
Next steps
Section titled “Next steps”- Hierarchical Traffic Light — a simpler introduction to nesting vs flattening
- Hierarchical Checkout — nested submachine inside a larger checkout flow
- Hierarchical Machines guide — full reference