
Stop Writing Implicit State Machines - Algebraic Types Make Reactive Code Bulletproof
Every time you write useState or create a signal, you're building a finite state machine. The problem? You're doing it implicitly, without the diagram, and your compiler can't save you from impossible states.
Reactive programming's dirty secret is that most bugs stem from unmodeled state transitions. You have loading flags, error messages, and data all floating independently, creating combinations like "loading=false, error=null, data=null" that shouldn't exist but crash your app anyway.
The solution isn't better state management—it's making your state machines explicit through algebraic data types.
The Algebra of State
Algebraic data types (ADTs) give you two powerful tools:
- Sum types (unions/enums): "This OR that" - perfect for mutually exclusive states
- Product types (structs/records): "This AND that" - for combining related data
Instead of this implicit mess:
1// Implicit FSM - 12 possible combinations, only 4 valid
2const [loading, setLoading] = useState(false)
3const [error, setError] = useState(null)
4const [data, setData] = useState(null)
5
6// What does this even mean?
7if (!loading && !error && !data) {
8 // Is this idle? Empty? Broken?
9}You get explicit, impossible-to-misuse state:
1// Explicit FSM - exactly 4 valid states
2type FetchState<T> =
3 | { type: 'idle' }
4 | { type: 'loading' }
5 | { type: 'success', data: T }
6 | { type: 'error', message: string }
7
8const [state, setState] = useState<FetchState<User>>({ type: 'idle' })<> The compiler now enforces exhaustive handling - you literally cannot forget to handle a state without TypeScript screaming at you./>
Signals as State Projections
Here's where it gets interesting. Signals become pure functions that project from your ADT state. No more wondering if your derived state is stale:
1class UserStore {
2 private state = signal<FetchState<User>>({ type: 'idle' })
3
4 // Signals as lenses into the ADT
5 isLoading = computed(() => this.state().type === 'loading')
6 error = computed(() => {
7 const s = this.state()
8 return s.type === 'error' ? s.message : nullNotice what happened: Your signals automatically update because they're pure projections from a single source of truth. No manual synchronization, no stale derived state.
Effects as Exhaustive Handlers
Effects become pattern-matched event handlers that the compiler can verify are complete:
1function renderUserProfile(state: FetchState<User>): JSX.Element {
2 switch (state.type) {
3 case 'idle':
4 return <div>Click to load user</div>
5 case 'loading':
6 return <Spinner />
7 case 'success':
8 return <Profile user={state.data} />
9 case 'error':
10 return <ErrorMessage message={state.message} />
11 // TypeScript error if you miss a case!
12 }
13}The beauty is exhaustivity checking. Add a new state to your ADT, and every switch statement breaks until you handle it. No more "it worked in dev but crashed in prod" surprises.
Beyond Simple Loading States
This pattern scales to complex domains. Consider a form with validation:
1type FormState<T> =
2 | { type: 'editing', draft: Partial<T>, errors: string[] }
3 | { type: 'validating', draft: T }
4 | { type: 'valid', data: T }
5 | { type: 'submitting', data: T }
6 | { type: 'submitted', result: SubmissionResult }
7 | { type: 'failed', data: T, error: string }
8
9// Each state carries exactly the data it needs
10// Impossible states like "submitting with errors" can't existOr a game entity:
1#[derive(Clone, Debug)]
2enum PlayerState {
3 Idle { position: Vec2, health: u32 },
4 Moving { from: Vec2, to: Vec2, progress: f32, health: u32 },
5 Attacking { position: Vec2, target: EntityId, health: u32 },
6 Dead { position: Vec2, death_time: f64 },
7}
8<> Every state transition is explicit, every state combination is valid by construction, and the compiler ensures you handle every case./>
Drawing the Algebra
The real magic happens when you start with the state diagram. Sketch your FSM first:
1[Idle] --fetch--> [Loading] --success--> [Success]
2 ^ | |
3 | +--error--> [Error]---+
4 +------------------reset----------------+Then encode it as an ADT. Each node becomes a sum type variant, each edge becomes a transition function. The diagram IS your type system.
Why This Matters
Algebraic state machines eliminate entire classes of bugs at compile time. Studies on algebraic effects show 50-80% reduction in state-related errors. But more importantly, they make your code self-documenting and fearless to refactor.
When you add a new state, the compiler forces you to update every handler. When you remove one, it shows you exactly what breaks. Your FSM becomes a living contract between your domain model and your UI.
Start small: pick one component with implicit state (probably involving loading/error handling) and refactor it to an explicit ADT. Draw the diagram first, encode it as types second, implement the transitions third. Once you feel the safety net of exhaustivity checking, you'll never go back to boolean soup.
The algebra between signals and effects isn't just academic—it's the difference between reactive code that breaks silently and reactive code that breaks loudly at compile time, exactly when you want it to.

