State Machines for the Rest of Us
Letβs say we have a button that should have 4 distinct appearances. Each appearance is a state: a named position the component can occupy at any given moment. The button has four states:
idleloadingsuccesserror
A state controller is an object that holds the current state and exposes only the methods defined for that state. It differs from a full state machine library: the controller carries no transition table, no guards, and no side-effect hooks. It holds a current-state value and a per-state method map.
While the button is idle, it can be clicked and switches to loading. Once the loading is done, it switches to success or error and remains there until clicked again.
Flag-based approaches make it easy to reach impossible combinations: with three flags (isLoading, isSuccess, isError), nothing prevents setting all three to true simultaneously. Ready-made libraries like xstate or machina.js solve this, but they model transitions as explicit (state, event) pairs β every legal path named up front. For this button, most (state, event) pairs are illegal anyway; the controller below encodes that by simply omitting the method. A description of states and the methods available in each state is enough. For example:
const buttonController = stateController('idle', {
idle: {
click: async () => {
buttonController.state = 'loading';
try {
// ... do something
buttonController.state = 'success';
} catch {
buttonController.state = 'error';
}
},
},
loading: {},
success: {
click: () => {
buttonController.state = 'idle';
},
},
error: {
click: () => {
buttonController.state = 'idle';
},
},
});
buttonController.click(); // switches to loading, then success or error- State: idle
- Clicks: 0
The implementation is straightforward. The tricky part is getting the types right.
Types
Each state defines its own method map. The controller exposes the union of all those maps as a flat API, so calling buttonController.click() works regardless of which state defines it. State methods are provided as an argument, but are usable as properties of the controller instance. This requires a type for all the methods combined.
type StateMethods = Record<string, Record<string, AnyFn>>;
type State<SM extends StateMethods> = keyof SM;
// SM[keyof SM] would give a union type { ... } | { ... }
// but we need an intersection { ... } && { ... }
// to get all the methods combined
// luckily there is a way to do this:
// https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I extends U,
) => void
? I
: never;
// this way we get
type Methods<SM extends StateMethods> = UnionToIntersection<SM[keyof SM]>;
// eventually we'd need controller to have its own type intersected with Methods<StateMethods>
type Controller<SM extends StateMethods> = {
state: State<SM>;
};Extending Controller with Methods
attachMethods is the core of the state controller. It takes the controller instance and the state methods, and returns a new controller instance with the methods attached.
export function attachMethods<
SM extends StateMethods,
SC extends IStateController<SM>,
>(controller: SC, stateMethods: SM) {
const methods = {} as Record<string, UnknownFn>;
for (const stateKey of Object.keys(stateMethods)) {
for (const methodKey of Object.keys(stateMethods[stateKey]!)) {
if (methodKey in methods) continue; // skip if already exists
methods[methodKey] = (...args) => {
const availableMethods = stateMethods[controller.state!];
return availableMethods?.[methodKey]?.(...args);
};
}
}
return merge(controller, { stateMethods }, methods as Methods<SM>);
} TypeScript shaped most of the implementation decisions here:
- We create an empty object for the methods first, so
methods[methodKey]does not complain about a type mismatch. - We use a
mergefunction, which is a type-safe version ofObject.assign. - We pass
stateMethodsas an argument rather than a property ofController, which lets TypeScript infer the generic types and avoids unexpected type errors. The alternative would require writingattachMethods<SM, Controller<SM>>(stateController)explicitly. - We merge
stateMethodsinto the controller so it is available as a property of the instance, making it possible to reuse the methods.
State Controller as an Object
The state controller can be constructed from a plain object. The current state is stored in a Svelte $state rune, which makes it reactive:
function stateController<SM extends StateMethods>(
defaultState: State<SM>,
stateMethods: SM,
) {
let state = $state<State<SM>>(defaultState);
const stateController = {
get state() {
return state;
},
set state(value: State<SM>) {
state = value;
},
};
return attachMethods(stateController, stateMethods);
}State Controller as a Class
class StateController<SM extends StateMethods> {
state = $state<State<SM>>();
protected constructor(defaultState: State<SM>) {
this.state = defaultState;
}
static create<SM extends StateMethods>(
defaultState: State<SM>,
stateMethods: SM,
) {
return attachMethods(new this(defaultState), stateMethods);
}
}
function stateController<SM extends StateMethods>(
defaultState: State<SM>,
stateMethods: SM,
) {
return StateController.create(defaultState, stateMethods);
} The constructor is protected because TypeScript cannot extend the return type inside a constructor. A static factory method gives back an instance of the correct type.
A custom ButtonController class bakes in the state methods directly:
class ButtonController {
state = $state<keyof this['stateMethods']>('idle');
stateMethods = {
idle: {
click: async () => {
this.state = 'loading';
try {
// ... do something
this.state = 'success';
} catch {
this.state = 'error';
}
},
},
loading: {},
success: {
click: () => {
this.state = 'idle';
},
},
error: {
click: () => {
this.state = 'idle';
},
},
};
protected constructor() {}
static create() {
const instance = new this();
return attachMethods(instance, instance.stateMethods);
}
}
const buttonController = ButtonController.create();Conclusion
The price of autocompletion is high π . TypeScript sometimes leads you to non-obvious solutions, but the result is compile-time exhaustiveness over states, autocompletion on every method, and no runtime guard mistakes.
The state controller pattern fits UI components that have a fixed set of states and associated interactions. Defining states and their method maps explicitly makes the control flow visible and the code easier to follow.
