TypeScript 52 ๐ท Type-Safe State Machines
A state machine is a model of a system that has a finite set of states, a set of events that trigger transitions between them, and rules about which transitions are allowed. The pattern is old and well understood, but it is usually implemented with strings and conditionals: a state variable that holds a string, a switch on that string, and an event handler that checks whether the transition is valid. The result works, but nothing prevents an invalid transition from being written, and nothing tells the caller which events are available in the current state. TypeScript can do better. By encoding states as a union of literal types, transitions as a map from state to available events, and events as discriminated unions, the type system can reject invalid transitions at compile time, guide the caller with autocomplete, and make the set of reachable states explicit. This chapter covers the design of a type-safe state machine, the patterns for defining states and transitions, the techniques for narrowing the current state, and the tradeoffs that determine when a full state machine library is worth the dependency.
Key point: A type-safe state machine has three parts: a state union that lists every possible state as a literal type, an event union that lists every event, and a transition map that declares, for each state, which events are valid and what state they lead to. The transition map is the core โ it is typed so that a transition from a state that does not exist, or with an event the state does not handle, is a compile error. The current state is held as a value of the state union, and discriminant narrowing lets the code access state-specific data safely. Libraries like XState provide this and more, but a hand-written machine of about a hundred lines covers most application needs without a dependency.
Why state machines matter
A state machine is a way to make the set of valid states and transitions explicit. The alternative โ a set of booleans and strings โ is what most applications start with, and it is what produces the bugs that state machines prevent.
Booleans multiply. An object with isLoading, isError, and isSuccess has eight combinations, and most of them are invalid. The type system sees three independent booleans and cannot reject isLoading: true, isSuccess: true. A state union with 'loading' | 'error' | 'success' has exactly three states, and the invalid combinations are not representable.
Impossible states are unrepresentable. This is the core benefit. A checkout flow with states 'cart' | 'shipping' | 'payment' | 'complete' cannot be in two states at once, cannot skip from 'cart' to 'complete', and cannot receive a PAY event while in 'cart'. The type system enforces these rules if the machine is designed correctly.
Transitions are explicit. A transition map declares, for each state, which events are valid and what state they lead to. The map is the specification, and it is checked by the compiler. Adding a new state means adding an entry to the map, and the compiler reports every place that needs to handle it.
State-specific data is scoped. A state machine’s states often carry different data โ an 'error' state has an error message, a 'success' state has a result. The discriminated union makes the data accessible only in the state that carries it, and the compiler enforces the access.
Why the pattern is not always used. State machines add ceremony. For a simple toggle, a boolean is enough. The pattern is worth it when the system has several states, transitions that must be validated, and side effects that depend on the state. The cost is a small amount of type machinery; the benefit is that the invalid states and transitions are unrepresentable.
Why the type-level approach is better than a runtime library alone. XState and similar libraries validate transitions at runtime. That is useful, but the error appears when the invalid transition is attempted, not when it is written. The type-level approach rejects the invalid transition at compile time, which is earlier and cheaper. The two can be combined: a library for the runtime behavior and types for the compile-time checking.
States and events as unions
The foundation is two unions: one for the states and one for the events. Each is a union of literal types, and each can carry data.
type State =
| { type: 'idle' }
| { type: 'loading'; startedAt: number }
| { type: 'success'; data: string[] }
| { type: 'error'; message: string };
type Event =
| { type: 'FETCH' }
| { type: 'RESOLVE'; data: string[] }
| { type: 'REJECT'; message: string }
| { type: 'RESET' };
Each state is an object with a type field that is a literal, and the other fields carry the state-specific data. The type field is the discriminant โ the field that TypeScript uses to narrow the union. Each event is an object with a type field and its own data.
Why the discriminant is type. The name is a convention. It could be kind, tag, or anything else, as long as it is consistent. The type field is the most common because it reads naturally and matches the way Redux actions are shaped. The discriminant is what makes narrowing work โ if (state.type === 'success') narrows state to the success variant, and the compiler knows that state.data exists.
Why data is in the state, not separate. A state that carries its data makes the data available exactly when it is relevant. The 'success' state carries data, and the 'error' state carries message. There is no way to access data in the 'error' state, and no way to have a 'success' state without data. The alternative โ a separate data field that is sometimes populated โ allows invalid combinations.
Why events are also a discriminated union. Each event has a type and its own payload. The union makes it possible to handle events by their type and access the payload safely. A RESOLVE event carries data, a REJECT event carries message, and the handler can distinguish them.
Why the state and event unions should be exhaustive. Every state the machine can be in should be in the state union. Every event the machine can receive should be in the event union. The compiler checks the transition map against these unions, so an unlisted state or event is a compile error. This is what makes the machine’s behavior complete โ there is no state or event that is handled implicitly.
The transition map
The transition map declares, for each state, which events are valid and what state they lead to. It is the specification of the machine.
type Transitions = {
[S in State['type']]: {
[E in Event['type']]?: State['type'];
};
};
This type says: for each state type S, there is a map from event type E to the resulting state type. The ? makes every transition optional โ a state does not have to handle every event. An event that is not listed for a state is not a valid transition from that state.
Why the map is indexed by state and event types. The mapped type iterates over the state types and the event types, producing a nested map. The result is a type that has an entry for every state, and within each state, an optional entry for every event. The compiler can check that the map is complete for the states that exist and that the transitions lead to valid states.
Why the transition target is a state type, not a state value. The map records which state the transition leads to, not the data. The data is produced by the transition handler โ a function that receives the current state and the event and returns the new state. The map is the structure; the handlers are the behavior.
The map as a value. The type alone does not do anything โ the machine needs a value of that type.
const transitions: Transitions = {
idle: {
FETCH: 'loading',
},
loading: {
RESOLVE: 'success',
REJECT: 'error',
},
success: {
RESET: 'idle',
},
error: {
RESET: 'idle',
},
};
The value is checked against the type. A transition from 'idle' with 'RESOLVE' would be a compile error because 'idle' does not list 'RESOLVE'. A transition to a state that is not in the state union would be a compile error. The compiler enforces the rules.
Why some transitions are absent. A state that does not list an event does not handle that event. The 'success' state handles RESET but not FETCH, because fetching from a success state is not a valid operation. The absence is the specification โ it says “this transition is not allowed.”
Why the map should be as const or typed. The map should be typed as Transitions so that the compiler checks it. Without the type annotation, the map is inferred as its literal shape, and the checking is weaker. The annotation is what makes the map a checked specification.
The machine
The machine holds the current state and the transition handlers. It exposes a send method that takes an event and produces a new state, or reports that the transition is invalid.
class Machine {
private state: State = { type: 'idle' };
send(event: Event): State {
const currentType = this.state.type;
const nextType = transitions[currentType][event.type];
if (!nextType) {
throw new Error(`Invalid transition: ${currentType} + ${event.type}`);
}
this.state = this.reduce(this.state, event);
return this.state;
}
private reduce(state: State, event: Event): State {
switch (state.type) {
case 'idle':
if (event.type === 'FETCH') {
return { type: 'loading', startedAt: Date.now() };
}
break;
case 'loading':
if (event.type === 'RESOLVE') {
return { type: 'success', data: event.data };
}
if (event.type === 'REJECT') {
return { type: 'error', message: event.message };
}
break;
case 'success':
if (event.type === 'RESET') {
return { type: 'idle' };
}
break;
case 'error':
if (event.type === 'RESET') {
return { type: 'idle' };
}
break;
}
return state;
}
}
The send method checks the transition map, and if the transition is valid, it calls the reducer to produce the new state. The reducer is a switch over the state type and the event type, and each branch produces the new state with the appropriate data.
Why the transition check and the reducer are separate. The transition map is the structure โ which transitions are valid. The reducer is the behavior โ what the new state is. Separating them means the structure is checked by the compiler and the behavior is written by the developer. The check catches invalid transitions; the reducer handles the valid ones.
Why the reducer returns a state and not a mutation. Immutable updates are the standard for state machines because the previous state is often needed โ for history, for undo, for comparison. Returning a new state means the old one is preserved, and the change is explicit.
Why the send method throws on invalid transitions. The type system catches most invalid transitions at compile time, but events can arrive from dynamic sources โ a user click, a network response โ where the type is not statically known. The runtime check catches those cases. Throwing is the right behavior for a programming error; a result type would be better if the invalid transition is expected.
Why the switch is exhaustive. The compiler can check that every state type is handled in the switch. If a state is added to the union and not handled, the compiler reports it. This is how the machine stays complete as it evolves.
Narrowing the current state
The discriminant on the state union allows the current state’s data to be accessed safely. The pattern is to check the type and then use the data.
function render(state: State): string {
switch (state.type) {
case 'idle':
return 'Ready';
case 'loading':
return `Loading since ${state.startedAt}`;
case 'success':
return `Loaded ${state.data.length} items`;
case 'error':
return `Error: ${state.message}`;
}
}
Each branch narrows state to the specific variant, and the compiler knows that state.data exists in the 'success' branch and state.message in the 'error' branch. Accessing state.data in the 'loading' branch would be a compile error.
Why narrowing is the payoff of the union. The state union carries the data, and the narrowing makes the data accessible exactly where it is valid. There is no need for optional chaining, no need for type assertions, and no way to access the wrong data. The compiler enforces the access.
Why the switch is preferred over if-chains. A switch on the discriminant is exhaustive-checked by the compiler โ if a case is missing, the compiler reports it. An if-chain is not, and a missing branch is a silent bug. The switch is the safer form.
Why the exhaustiveness check is worth enabling. The never type can be used to enforce exhaustiveness explicitly: default: const _exhaustive: never = state;. If a state is added and not handled, the assignment fails, and the compiler reports the missing case. This is a stronger check than the switch’s implicit one.
Why the state should be exposed as a value. The machine’s current state should be readable by the code that renders it or decides what to do next. Exposing it as a value of the state union means the consumer gets the same narrowing benefits as the machine. The machine can also expose it through a subscriber callback for reactive use.
Side effects and async transitions
A real state machine often has transitions that trigger side effects โ a fetch, a save, a navigation. The side effects should be separated from the transition logic, so the machine remains a pure function of state and event.
Why side effects should be outside the reducer. The reducer should be pure โ same inputs, same output, no I/O. This makes it testable and predictable. The side effect is triggered by the caller or by a subscription to the state changes, not by the reducer.
async function fetchData(machine: Machine) {
machine.send({ type: 'FETCH' });
try {
const data = await fetch('/api/data').then((r) => r.json());
machine.send({ type: 'RESOLVE', data });
} catch (error) {
machine.send({ type: 'REJECT', message: String(error) });
}
}
The fetchData function sends the FETCH event, performs the async work, and sends the RESOLVE or REJECT event based on the result. The machine does not know about the fetch โ it only knows about the events. The side effect is in the caller, which makes the machine reusable and testable.
Why async transitions need care. An async operation can complete after the state has changed. If the user navigates away, the RESOLVE event arrives in a different state and may be invalid. The machine’s transition check catches this โ the event is not valid from the current state, and the send throws or ignores. The safe pattern is to check the state before sending, or to use a cancellation token.
Why the machine should be observable. A state machine that notifies subscribers of state changes is easier to integrate with a UI. The subscriber pattern โ subscribe(listener) and notify โ is the standard. The machine can also be wrapped in an RxJS BehaviorSubject for integration with Angular or a signal for integration with modern frameworks.
Why XState exists. XState provides a full implementation of statecharts โ a superset of state machines with hierarchical states, parallel states, guards, actions, and a visualization tool. The hand-written machine covers flat state machines; XState covers the complex cases. The choice is between a dependency and a few hundred lines, and it depends on how complex the machine needs to be.
Why the hand-written machine is often enough. Most application state machines are flat โ a handful of states, a dozen transitions, no hierarchy. A hand-written machine of a hundred lines covers this, and it is fully type-safe, fully testable, and has no dependency. XState is the right choice when the machine is genuinely complex โ parallel states, nested states, guards, or the visualization is needed.
Guards and conditional transitions
A guard is a condition that must be true for a transition to be valid. It allows the same event to lead to different states depending on the current data.
type Guard<S extends State, E extends Event> = (
state: S,
event: E,
) => boolean;
The guard receives the current state and the event and returns a boolean. The transition is valid only if the guard returns true. Guards add a layer of logic that the transition map alone cannot express.
Why guards are useful. A form submission might transition to 'submitting' only if the form is valid, and to 'error' otherwise. The guard captures the condition, and the transition map declares both targets. Without guards, the condition would have to be checked in the caller, which spreads the logic.
Why guards complicate the type-level model. A guard’s return type is a boolean, which the compiler cannot evaluate. The transition map declares both possible targets, and the guard decides at runtime. The type system cannot reject a guard that always returns false, because it cannot evaluate the condition. Guards are a runtime feature, and the type system only checks that the declared targets are valid.
Why guards should be pure. A guard that performs I/O or has side effects makes the machine unpredictable. Guards should be pure predicates over the state and event, so the machine’s behavior is deterministic given the same inputs.
Why guards are the boundary of static checking. The transition map is checked at compile time; the guards are checked at runtime. This is the limit of what the type system can do for a state machine โ the structure is static, the conditions are dynamic. The type system catches the structural errors, and the tests catch the logical ones.
Complete Example Session
// ============================================
// PART 1: STATES AND EVENTS
// ============================================
type State =
| { type: 'idle' }
| { type: 'loading'; startedAt: number }
| { type: 'success'; data: string[] }
| { type: 'error'; message: string };
type Event =
| { type: 'FETCH' }
| { type: 'RESOLVE'; data: string[] }
| { type: 'REJECT'; message: string }
| { type: 'RESET' };
// ============================================
// PART 2: TRANSITION MAP
// ============================================
type Transitions = {
[S in State['type']]: {
[E in Event['type']]?: State['type'];
};
};
const transitions: Transitions = {
idle: { FETCH: 'loading' },
loading: { RESOLVE: 'success', REJECT: 'error' },
success: { RESET: 'idle' },
error: { RESET: 'idle' },
};
// ============================================
// PART 3: THE MACHINE
// ============================================
class Machine {
private state: State = { type: 'idle' };
private readonly listeners = new Set<(state: State) => void>();
getState(): State {
return this.state;
}
subscribe(listener: (state: State) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
send(event: Event): State {
const currentType = this.state.type;
const nextType = transitions[currentType][event.type];
if (!nextType) {
throw new Error(`Invalid transition: ${currentType} + ${event.type}`);
}
this.state = this.reduce(this.state, event);
this.listeners.forEach((l) => l(this.state));
return this.state;
}
private reduce(state: State, event: Event): State {
switch (state.type) {
case 'idle':
if (event.type === 'FETCH') {
return { type: 'loading', startedAt: Date.now() };
}
break;
case 'loading':
if (event.type === 'RESOLVE') {
return { type: 'success', data: event.data };
}
if (event.type === 'REJECT') {
return { type: 'error', message: event.message };
}
break;
case 'success':
if (event.type === 'RESET') {
return { type: 'idle' };
}
break;
case 'error':
if (event.type === 'RESET') {
return { type: 'idle' };
}
break;
}
return state;
}
}
// ============================================
// PART 4: USING THE MACHINE
// ============================================
const machine = new Machine();
machine.subscribe((state) => console.log('state:', state.type));
machine.send({ type: 'FETCH' }); // idle โ loading
machine.send({ type: 'RESOLVE', data: [] }); // loading โ success
machine.send({ type: 'RESET' }); // success โ idle
// machine.send({ type: 'RESOLVE', data: [] });
// โ Invalid transition: idle + RESOLVE (runtime error)
// ============================================
// PART 5: NARROWING
// ============================================
function describe(state: State): string {
switch (state.type) {
case 'idle':
return 'Ready';
case 'loading':
return `Loading since ${state.startedAt}`;
case 'success':
return `Loaded ${state.data.length} items`;
case 'error':
return `Error: ${state.message}`;
}
}
// ============================================
// PART 6: EXHAUSTIVENESS CHECK
// ============================================
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function describeExhaustive(state: State): string {
switch (state.type) {
case 'idle':
return 'Ready';
case 'loading':
return 'Loading';
case 'success':
return 'Loaded';
case 'error':
return 'Error';
default:
return assertNever(state);
}
}
// ============================================
// PART 7: ASYNC TRANSITIONS
// ============================================
async function fetchData(machine: Machine): Promise<void> {
machine.send({ type: 'FETCH' });
try {
const data = await fetch('/api/data').then((r) => r.json());
machine.send({ type: 'RESOLVE', data });
} catch (error) {
machine.send({ type: 'REJECT', message: String(error) });
}
}
// ============================================
// PART 8: GUARDED TRANSITIONS
// ============================================
type FormState =
| { type: 'editing'; valid: boolean }
| { type: 'submitting' }
| { type: 'submitted' }
| { type: 'failed'; error: string };
type FormEvent = { type: 'SUBMIT' } | { type: 'SUCCESS' } | { type: 'FAILURE'; error: string };
function reduceForm(state: FormState, event: FormEvent): FormState {
switch (state.type) {
case 'editing':
if (event.type === 'SUBMIT' && state.valid) {
return { type: 'submitting' };
}
return state;
case 'submitting':
if (event.type === 'SUCCESS') return { type: 'submitted' };
if (event.type === 'FAILURE') return { type: 'failed', error: event.error };
return state;
default:
return state;
}
}
// ============================================
// PART 9: COMPILE-TIME ERRORS
// ============================================
// Invalid state in the transition map:
// const bad: Transitions = {
// idle: { FETCH: 'nonexistent' }, // โ 'nonexistent' is not a State type
// loading: {},
// success: {},
// error: {},
// };
// Invalid event in the transition map:
// const bad: Transitions = {
// idle: { TELEPORT: 'loading' }, // โ 'TELEPORT' is not an Event type
// loading: {},
// success: {},
// error: {},
// };
// ============================================
// PART 10: TESTING
// ============================================
function testTransitions() {
const m = new Machine();
console.assert(m.getState().type === 'idle');
m.send({ type: 'FETCH' });
console.assert(m.getState().type === 'loading');
m.send({ type: 'RESOLVE', data: ['a'] });
console.assert(m.getState().type === 'success');
}
The ten parts cover the state and event unions, the transition map, the machine, usage, narrowing, exhaustiveness, async transitions, guards, compile-time errors, and testing.
Quick Reference
Core Types
| Type | Purpose |
|---|---|
State | Union of state variants |
Event | Union of event variants |
Transitions | Map from state to event to next state |
| Discriminant | type field for narrowing |
The Machine API
| Method | Purpose |
|---|---|
send(event) | Apply an event |
getState() | Read the current state |
subscribe(listener) | Observe state changes |
reduce(state, event) | Pure transition logic |
Patterns
| Pattern | Example |
|---|---|
| State union | { type: 'idle' } | { type: 'loading' } |
| Event union | { type: 'FETCH' } | { type: 'RESOLVE' } |
| Transition map | { idle: { FETCH: 'loading' } } |
| Narrowing | switch (state.type) |
| Exhaustiveness | assertNever(state) |
| Guard | (state, event) => boolean |
| Async | send after await |
When to Use
| Situation | State machine? |
|---|---|
| Simple toggle | โ (boolean) |
| Multiple states with rules | โ |
| Transitions that must be validated | โ |
| State-specific data | โ |
| Parallel or nested states | โ (XState) |
| Side effects on transitions | โ (with separation) |
Best Practices
โ Do This:
// Use discriminated unions for states
type State = { type: 'idle' } | { type: 'loading' }; // โ
// Type the transition map
const transitions: Transitions = { ... }; // โ
// Check the transition before reducing
const nextType = transitions[currentType][event.type]; // โ
// Narrow with a switch
switch (state.type) { case 'idle': return 'Ready'; } // โ
// Enforce exhaustiveness
default: return assertNever(state); // โ
// Keep the reducer pure
private reduce(state: State, event: Event): State { ... } // โ
// Separate side effects from the reducer
async function fetchData(machine: Machine) { ... } // โ
// Test transitions with assertions
console.assert(m.getState().type === 'idle'); // โ
โ Don’t Do This:
// Don't use booleans for multiple states
{ isLoading: boolean; isError: boolean; isSuccess: boolean } // โ ๏ธ
// Don't use string literals without a union
let state: string = 'idle'; // no checking // โ ๏ธ
// Don't mutate the state in the reducer
state.data = event.data; // mutation, not a new state // โ ๏ธ
// Don't perform I/O in the reducer
private reduce() { fetch('/api'); } // impure // โ ๏ธ
// Don't skip the exhaustiveness check
// A missing state is a silent bug // โ ๏ธ
// Don't rely on guards for structure
// The type system cannot evaluate a guard // โ ๏ธ
// Don't send events without checking the state after async
// The state may have changed // โ ๏ธ
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Booleans for states | Invalid combinations | Use a union |
| Untyped transition map | No checking | Annotate with Transitions |
| Missing exhaustiveness | Silent bug | assertNever |
| Impure reducer | Unpredictable | Separate side effects |
| Mutating state | History lost | Return a new state |
| Async event after state change | Invalid transition | Check state or cancel |
| Guards used for structure | No compile check | Use the transition map |
| String state without a union | No narrowing | Use discriminated union |
Real-World Examples
1. Fetch machine
type State = { type: 'idle' } | { type: 'loading' } | { type: 'success' } | { type: 'error' };
2. Transition map
const transitions: Transitions = {
idle: { FETCH: 'loading' },
loading: { RESOLVE: 'success', REJECT: 'error' },
success: { RESET: 'idle' },
error: { RESET: 'idle' },
};
3. Send an event
machine.send({ type: 'FETCH' });
4. Narrow the state
switch (state.type) {
case 'success': return state.data;
}
5. Exhaustiveness
default: return assertNever(state);
6. Async transition
machine.send({ type: 'FETCH' });
const data = await fetchData();
machine.send({ type: 'RESOLVE', data });
7. Subscribe
machine.subscribe((state) => console.log(state.type));
8. Guard
if (event.type === 'SUBMIT' && state.valid) return { type: 'submitting' };
9. Test
m.send({ type: 'FETCH' });
console.assert(m.getState().type === 'loading');
10. Invalid transition
// machine.send({ type: 'RESOLVE' }); // runtime error if not valid
Visual: States and Transitions
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ FETCH RESOLVE โ
โ idle โโโโโโโบ loading โโโโโโโโโโโบ success โ
โ โ โ โ
โ โ REJECT โ RESET โ
โ โผ โ โ
โ error โโโโโโโโโโโโโโโโ โ
โ โ โ
โ โ RESET โ
โ โผ โ
โ idle โ
โ โ
โ Each arrow is a transition declared in the map. โ
โ An event not on an arrow is invalid from that state. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: The Transition Map as a Type
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Transitions = { โ
โ [S in State['type']]: { โ
โ [E in Event['type']]?: State['type']; โ
โ }; โ
โ }; โ
โ โ
โ Expands to: โ
โ โ
โ { โ
โ idle: { โ
โ FETCH?: 'idle' | 'loading' | 'success' | 'error'; โ
โ RESOLVE?: ...; REJECT?: ...; RESET?: ...; โ
โ }; โ
โ loading: { ... }; โ
โ success: { ... }; โ
โ error: { ... }; โ
โ } โ
โ โ
โ The compiler checks the map against this shape. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Discriminant Narrowing
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type State = โ
โ | { type: 'idle' } โ
โ | { type: 'loading'; startedAt: number } โ
โ | { type: 'success'; data: string[] } โ
โ | { type: 'error'; message: string }; โ
โ โ
โ switch (state.type) { โ
โ case 'idle': โ
โ state.data โ โ idle has no data โ
โ โ
โ case 'loading': โ
โ state.startedAt โ
โ
โ โ
โ case 'success': โ
โ state.data โ
โ
โ state.message โ โ
โ โ
โ case 'error': โ
โ state.message โ
โ
โ } โ
โ โ
โ The compiler knows which fields exist in each branch. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Side Effects Outside the Reducer
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PURE REDUCER โ
โ โ
โ reduce(state, event) โโโบ new state โ
โ No I/O, no side effects, deterministic. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ SIDE EFFECTS โ
โ โ
โ async function fetchData(machine) { โ
โ machine.send({ type: 'FETCH' }); โ
โ try { โ
โ const data = await fetch('/api'); โ
โ machine.send({ type: 'RESOLVE', data }); โ
โ } catch (e) { โ
โ machine.send({ type: 'REJECT', message: String(e) });โ
โ } โ
โ } โ
โ โ
โ The machine handles events; the caller handles I/O. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Exhaustiveness Check
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function assertNever(value: never): never { โ
โ throw new Error(`Unhandled: ${value}`); โ
โ } โ
โ โ
โ switch (state.type) { โ
โ case 'idle': return 'Ready'; โ
โ case 'loading': return 'Loading'; โ
โ case 'success': return 'Loaded'; โ
โ case 'error': return 'Error'; โ
โ default: return assertNever(state); โ
โ } โ
โ โ
โ Add a new state to the union: โ
โ | { type: 'cancelled' } โ
โ โ
โ The compiler reports: โ
โ Argument of type '{ type: "cancelled" }' is not โ
โ assignable to parameter of type 'never'. โ
โ โ
โ The missing case is caught at compile time. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| State | Discriminated union with type |
| Event | Discriminated union with type |
| Transition map | [S in State['type']]: { [E in Event['type']]?: State['type'] } |
| Machine API | send, getState, subscribe |
| Reducer | Pure function from state and event to state |
| Narrowing | switch (state.type) |
| Exhaustiveness | assertNever(state) |
| Guards | Runtime conditions, not compile-checked |
| Side effects | Outside the reducer |
| Library | XState for complex machines |
Key takeaways:
- A state machine makes the valid states and transitions explicit โ the type system enforces what the runtime alone cannot
- States and events are discriminated unions โ the
typefield is the discriminant, and it enables narrowing and exhaustive checking - The transition map is the specification โ it declares which events are valid from each state, and the compiler checks it against the state and event unions
- An unlisted transition is an invalid transition โ the absence of an entry in the map is the statement that the transition is not allowed
- The reducer is pure โ it takes the current state and the event and returns the new state, with no I/O and no mutation
- Side effects belong outside the reducer โ the caller sends the events, and the machine handles them without knowing about the fetch, the save, or the navigation
- Narrowing gives access to state-specific data โ
state.dataexists only in the'success'branch, and the compiler enforces it - Exhaustiveness checking catches missing states โ the
assertNeverpattern makes a new state a compile error everywhere it is not handled - Guards are runtime conditions โ they add logic the transition map cannot express, and the type system cannot evaluate them
- A hand-written machine is often enough โ XState is for hierarchical, parallel, or visualized machines, and the flat case is about a hundred lines
Remember: A state machine is a model of a system that has states, events, and transitions. The type system can make the states and transitions explicit, reject invalid ones at compile time, and give the caller autocomplete for the events available in the current state. The pattern is worth the ceremony when the system has several states and the rules about transitions matter. For a simple toggle, a boolean is enough; for a checkout flow, a state machine is the right tool.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!