"Loading spinner still showing after an error", "submit fires twice", "back button lands in an impossible screen" — most UI bugs are really state bugs: the app drifted into a combination you never intended. A finite state machine kills that whole class by making the legal states and the transitions between them explicit data. You're in exactly one state; an event either has a defined transition out of it or it's ignored. Libraries like XState are built on this; here you'll write the tiny interpreter at its heart.
Implement finiteStateMachine(config) returning a machine with send, can, matches, and subscribe, supporting guards and entry actions. See statecharts.dev.
function finiteStateMachine(config) {
return { state, context, send, can, matches, subscribe };
}
const m = finiteStateMachine({
initial: 'idle',
context: { data: null },
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
on: {
SUCCESS: { target: 'ok', action: (ctx, e) => { ctx.data = e.data; } },
ERROR: { target: 'fail', guard: (ctx, e) => e.fatal === true },
},
},
ok: {}, fail: {},
},
});
m.send('FETCH'); // -> 'loading'
m.send({ type: 'SUCCESS', data: 7 }); // -> 'ok', ctx.data === 7
on[event.type]. No entry → ignore the event (stay put). Events are strings or { type, ...payload }.guard, it fires only when guard(context, event) is truthy; otherwise the machine stays.action runs during the switch; the target state's entry runs on arrival. The initial state's entry runs once at creation.can, matches, subscribe — can(event) previews whether a transition would fire; matches(state) checks the current state; subscribe notifies on each real transition.We'll hold the current state and a context object, and make send a fixed pipeline: look up the transition, check its guard, run the action, switch, and run the target's entry.
The config is a graph — states are nodes and each on: { EVENT: target } is a labeled edge. The machine sits on one node. When an event arrives, we look for an edge with that label leaving the current node. No edge? Ignore it — that's the whole safety guarantee: undefined transitions can't move you anywhere. An edge with a guard only fires when the guard says so. When it does fire, we may run an action, then land on the target and run its entry.
Keep two things: state (a string) and context (shared data). send is deterministic — the same pipeline every time, with two early exits (no handler, or a failing guard) that leave everything untouched.
A bare lookup handles the happy path but nothing else:
function fsmNaive(config) {
let state = config.initial;
return {
get state() { return state; },
send(type) {
const target = config.states[state].on[type]; // throws if `on` is missing
if (target) state = target;
},
};
}
It crashes on a state with no on, ignores guards (so it can enter a state it shouldn't), never runs entry or transition actions, only accepts string events, and can't tell you whether an event is handled. Each of those is a real requirement — the interpreter has to normalise events, guard transitions, and fire actions.
function finiteStateMachine(config) {
let state = config.initial;
const context = { ...(config.context || {}) };
const listeners = new Set();
const normalize = (event) => (typeof event === 'string' ? { type: event } : event);
// The resolved transition for the current state + event, or null.
function resolve(event) {
const node = config.states[state];
const handler = node && node.on && node.on[event.type];
if (!handler) return null;
return typeof handler === 'string' ? { target: handler } : handler;
}
function runEntry(name, event) {
const entry = config.states[name] && config.states[name].entry;
if (entry) entry(context, event);
}
runEntry(state, { type: 'init' }); // initial entry, once
const machine = {
get state() { return state; },
get context() { return context; },
can(event) {
const e = normalize(event);
const t = resolve(e);
if (!t) return false;
return t.guard ? !!t.guard(context, e) : true;
},
send(event) {
const e = normalize(event);
const t = resolve(e);
if (!t) return machine; // no handler -> stay
if (t.guard && !t.guard(context, e)) return machine; // guard blocks -> stay
if (t.action) t.action(context, e); // transition action
state = t.target; // switch
runEntry(state, e); // entry of the target
listeners.forEach((fn) => fn(state, context)); // notify
return machine; // chainable
},
matches(target) { return state === target; },
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
return machine;
}
module.exports = { finiteStateMachine };
The pieces that fix the naive version: normalize accepts both string and object events; resolve safely returns null for a missing on/handler (no crash) and expands a shorthand string target into { target }; send checks the guard before mutating, then runs the transition action, switches, runs the target's entry, and notifies — but only when it actually fires. can runs the same lookup + guard without side effects.
m.send('FETCH') then m.send({ type: 'SUCCESS', data: 42 }) on the fetch machine:
state = 'idle', context = { data: null }; idle has no entry, so nothing runs.send('FETCH') → normalize to {type:'FETCH'}; resolve finds idle.on.FETCH = 'loading' → { target: 'loading' }. No guard. No action. state = 'loading'; loading.entry sets context.started = true; notify. Return machine.send({type:'SUCCESS', data:42}) → resolve finds loading.on.SUCCESS = { target:'success', action }. No guard. Run action(context, e) → context.data = 42. state = 'success'; success has no entry; notify.state === 'success', context === { data: 42, started: true }.Send an unhandled event (say 'FETCH' while in success) and resolve returns null → the machine stays and no listeners fire.
on exists — a terminal state (success: {}) has no on. Guard the lookup (node.on && node.on[type]) instead of indexing blindly.state, or a blocked transition still moves you. The guard is a gate, not a cleanup.{ type: 'SUCCESS', data }); normalise so guards and actions can read them.send.exit on the old state before entry on the new one, in a defined order with the transition action between.assign — instead of mutating context, XState-style actions return a new context, keeping each transition a pure (state, event) => nextState.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
"Loading spinner still showing after an error", "submit fires twice", "back button lands in an impossible screen" — most UI bugs are really state bugs: the app drifted into a combination you never intended. A finite state machine kills that whole class by making the legal states and the transitions between them explicit data. You're in exactly one state; an event either has a defined transition out of it or it's ignored. Libraries like XState are built on this; here you'll write the tiny interpreter at its heart.
Implement finiteStateMachine(config) returning a machine with send, can, matches, and subscribe, supporting guards and entry actions. See statecharts.dev.
function finiteStateMachine(config) {
return { state, context, send, can, matches, subscribe };
}
const m = finiteStateMachine({
initial: 'idle',
context: { data: null },
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
on: {
SUCCESS: { target: 'ok', action: (ctx, e) => { ctx.data = e.data; } },
ERROR: { target: 'fail', guard: (ctx, e) => e.fatal === true },
},
},
ok: {}, fail: {},
},
});
m.send('FETCH'); // -> 'loading'
m.send({ type: 'SUCCESS', data: 7 }); // -> 'ok', ctx.data === 7
on[event.type]. No entry → ignore the event (stay put). Events are strings or { type, ...payload }.guard, it fires only when guard(context, event) is truthy; otherwise the machine stays.action runs during the switch; the target state's entry runs on arrival. The initial state's entry runs once at creation.can, matches, subscribe — can(event) previews whether a transition would fire; matches(state) checks the current state; subscribe notifies on each real transition.We'll hold the current state and a context object, and make send a fixed pipeline: look up the transition, check its guard, run the action, switch, and run the target's entry.
The config is a graph — states are nodes and each on: { EVENT: target } is a labeled edge. The machine sits on one node. When an event arrives, we look for an edge with that label leaving the current node. No edge? Ignore it — that's the whole safety guarantee: undefined transitions can't move you anywhere. An edge with a guard only fires when the guard says so. When it does fire, we may run an action, then land on the target and run its entry.
Keep two things: state (a string) and context (shared data). send is deterministic — the same pipeline every time, with two early exits (no handler, or a failing guard) that leave everything untouched.
A bare lookup handles the happy path but nothing else:
function fsmNaive(config) {
let state = config.initial;
return {
get state() { return state; },
send(type) {
const target = config.states[state].on[type]; // throws if `on` is missing
if (target) state = target;
},
};
}
It crashes on a state with no on, ignores guards (so it can enter a state it shouldn't), never runs entry or transition actions, only accepts string events, and can't tell you whether an event is handled. Each of those is a real requirement — the interpreter has to normalise events, guard transitions, and fire actions.
function finiteStateMachine(config) {
let state = config.initial;
const context = { ...(config.context || {}) };
const listeners = new Set();
const normalize = (event) => (typeof event === 'string' ? { type: event } : event);
// The resolved transition for the current state + event, or null.
function resolve(event) {
const node = config.states[state];
const handler = node && node.on && node.on[event.type];
if (!handler) return null;
return typeof handler === 'string' ? { target: handler } : handler;
}
function runEntry(name, event) {
const entry = config.states[name] && config.states[name].entry;
if (entry) entry(context, event);
}
runEntry(state, { type: 'init' }); // initial entry, once
const machine = {
get state() { return state; },
get context() { return context; },
can(event) {
const e = normalize(event);
const t = resolve(e);
if (!t) return false;
return t.guard ? !!t.guard(context, e) : true;
},
send(event) {
const e = normalize(event);
const t = resolve(e);
if (!t) return machine; // no handler -> stay
if (t.guard && !t.guard(context, e)) return machine; // guard blocks -> stay
if (t.action) t.action(context, e); // transition action
state = t.target; // switch
runEntry(state, e); // entry of the target
listeners.forEach((fn) => fn(state, context)); // notify
return machine; // chainable
},
matches(target) { return state === target; },
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
return machine;
}
module.exports = { finiteStateMachine };
The pieces that fix the naive version: normalize accepts both string and object events; resolve safely returns null for a missing on/handler (no crash) and expands a shorthand string target into { target }; send checks the guard before mutating, then runs the transition action, switches, runs the target's entry, and notifies — but only when it actually fires. can runs the same lookup + guard without side effects.
m.send('FETCH') then m.send({ type: 'SUCCESS', data: 42 }) on the fetch machine:
state = 'idle', context = { data: null }; idle has no entry, so nothing runs.send('FETCH') → normalize to {type:'FETCH'}; resolve finds idle.on.FETCH = 'loading' → { target: 'loading' }. No guard. No action. state = 'loading'; loading.entry sets context.started = true; notify. Return machine.send({type:'SUCCESS', data:42}) → resolve finds loading.on.SUCCESS = { target:'success', action }. No guard. Run action(context, e) → context.data = 42. state = 'success'; success has no entry; notify.state === 'success', context === { data: 42, started: true }.Send an unhandled event (say 'FETCH' while in success) and resolve returns null → the machine stays and no listeners fire.
on exists — a terminal state (success: {}) has no on. Guard the lookup (node.on && node.on[type]) instead of indexing blindly.state, or a blocked transition still moves you. The guard is a gate, not a cleanup.{ type: 'SUCCESS', data }); normalise so guards and actions can read them.send.exit on the old state before entry on the new one, in a defined order with the transition action between.assign — instead of mutating context, XState-style actions return a new context, keeping each transition a pure (state, event) => nextState.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.