← Build log

State Machines Beat Boolean Flags: The Rust Enum Pattern That Killed 194 console.logs

May 2026
SoloBuilderRustStateMachineArchitectureBooleanFlagsTypeSafety

The specific structural move that made invalid app states uncompilable — and the migration pattern from “isRecording && !isProcessing && hasResult” to “match state { Idle, Recording, Processing, Done, Error }”.

Published: May 2026 Tags: #SoloBuilder #Rust #StateMachine #Architecture #BooleanFlags #TypeSafety


The “So What?” - Why You Should Care

Bottom Line Value: A concrete pattern for replacing boolean-flag state management with a Rust enum (or TypeScript discriminated union). The pattern that turns “I have 5 bools and I forget which combinations are valid” into “the compiler enforces my states.”

What You’ll Gain:

  • The state enum pattern (with code)
  • The transition function that prevents invalid moves
  • The frontend/backend type-sharing approach so both sides agree on state
  • A migration playbook from boolean-flag mess to clean state machine
  • ROI: ~2 days of refactoring. Eliminates an entire category of bugs and removes ~150–200 console.log statements that were debugging state confusion

Who This Helps: Solo builders whose app has 5+ boolean state flags (isLoading, isRecording, hasError, isProcessing, etc.) and spends increasing amounts of time debugging “wait, how did we get into THIS state?”

Why Bother: Boolean flag systems grow combinatorially. 5 flags = 32 possible states; 7 flags = 128. Most of those states are invalid, but nothing prevents you from reaching them. State enums make invalid states impossible to construct. The compiler becomes your test suite.


TLDR: The Pattern in 4 Steps

  1. Enumerate the valid states — usually 4–6 for a typical app (10 min)
  2. Replace bools with an enum — one source of truth (1–2 hours)
  3. Define legal transitions — explicit function that gates state changes (1–2 hours)
  4. Share the enum with the frontend — via Tauri events or codegen (2–3 hours)

Result: Boolean flag combinatorics → finite state machine. Compiler errors instead of runtime surprises. ~150–200 console.log lines you can delete because the state is now visible everywhere.


The Moment You’re Probably In

Open your codebase and search for boolean state variables. You’ll find:

const [isLoading, setIsLoading] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [hasError, setHasError] = useState(false);
const [hasResult, setHasResult] = useState(false);

Or, even worse, scattered across files:

// in RecordingService.ts
let _isRecording = false;

// in App.tsx
const [appReady, setAppReady] = useState(false);
const [transcriptionInFlight, setTranscriptionInFlight] = useState(false);

// in api/transcribe.ts
let pendingResult: Result | null = null;

And then somewhere in the code:

if (isRecording && !isProcessing && !hasError && !hasResult) {
  // start recording UI
} else if (!isRecording && isProcessing && !hasError) {
  // show spinner
} else if (!isRecording && !isProcessing && hasResult) {
  // show result
} else if (hasError) {
  // ... wait, what state were we in?
}

The symptoms:

  • You add a feature and it breaks because the new flag interacts weirdly with the old ones
  • You hit a state in production that “shouldn’t be possible” — both isRecording AND hasResult are true
  • You write console.log({ isLoading, isRecording, isProcessing, hasError }) 50 times to debug
  • You can’t easily explain to anyone (including yourself) what the valid states are

That’s not a bug; that’s a structural problem. Here’s the fix.


Step 1: Enumerate the Valid States (10 minutes)

Before any code, name the states. Out loud or on paper. For our voice recorder:

Idle       — app ready, no recording in progress
Recording  — user is speaking, capturing audio
Processing — recording done, transcribing
Done       — transcription complete, result available
Error      — something failed

5 states. That’s it. Notice how this collapses the 32-bit space of “5 booleans” into 5 valid configurations.

The rule: if you can’t name your states in 10 minutes, you don’t have a state machine — you have an accumulated junk drawer of flags. The naming is the work.

The other rule: if you have more than ~7 states, you probably have nested concerns. Split into multiple state machines (e.g., AppState + NetworkState + AuthState).


Step 2: Replace Bools with an Enum (1–2 hours)

In Rust:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "variant", rename_all = "lowercase")]
pub enum RecordingState {
    Idle,
    Recording(RecordingInfo),
    Processing(ProcessingInfo),
    Done(RecordingResult),
    Error(String),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecordingInfo {
    pub started_at: chrono::DateTime<chrono::Utc>,
    pub device_name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessingInfo {
    pub recording_duration_ms: u64,
    pub model_name: String,
}

Two non-obvious choices:

  1. Each variant carries its relevant data. Recording carries the start time and device. Done carries the result. The data is only available in the state where it’s relevant. This is the magic: you can’t “use the recording result” in the Idle state because the result isn’t there.

  2. The serde tags mean it serializes cleanly to JSON for the frontend ({ "variant": "recording", "started_at": "...", "device_name": "..." }). The discriminated union pattern survives the IPC boundary.

In TypeScript, the equivalent is a discriminated union:

type RecordingState =
  | { variant: 'idle' }
  | { variant: 'recording'; started_at: string; device_name: string }
  | { variant: 'processing'; recording_duration_ms: number; model_name: string }
  | { variant: 'done'; result: RecordingResult }
  | { variant: 'error'; message: string };

The enum prevents invalid states. The transition function prevents invalid moves between states.

impl RecordingState {
    pub fn can_transition_to(&self, next: &RecordingState) -> bool {
        use RecordingState::*;
        matches!(
            (self, next),
            (Idle, Recording(_)) |
            (Recording(_), Processing(_)) |
            (Recording(_), Error(_)) |
            (Processing(_), Done(_)) |
            (Processing(_), Error(_)) |
            (Done(_), Idle) |
            (Error(_), Idle)
        )
    }
}

pub fn transition(
    current: &Mutex<RecordingState>,
    next: RecordingState,
) -> Result<RecordingState, StateError> {
    let mut guard = current.lock().expect("state lock poisoned");
    if !guard.can_transition_to(&next) {
        return Err(StateError::InvalidTransition {
            from: format!("{:?}", *guard),
            to: format!("{:?}", next),
        });
    }
    *guard = next.clone();
    Ok(next)
}

The transition function is the ONLY place state changes. Every state mutation goes through it. Invalid transitions return errors at runtime.

The transitions for our voice recorder:

Idle ─→ Recording
Recording ─→ Processing
Recording ─→ Error
Processing ─→ Done
Processing ─→ Error
Done ─→ Idle (start a new recording)
Error ─→ Idle (dismiss the error)

Drawing this as a diagram once and keeping it in the codebase as a doc comment is worth the 10 minutes.


Step 4: Share the Enum with the Frontend (2–3 hours)

The hardest part of multi-process apps (Tauri, Electron, web+server) is keeping frontend and backend in sync about state. With an enum + serde, this is straightforward.

Emit state changes from Rust:

use tauri::Emitter;

pub fn apply_transition(
    handle: &tauri::AppHandle,
    current: &Mutex<RecordingState>,
    next: RecordingState,
) -> Result<(), StateError> {
    let new_state = transition(current, next)?;
    handle
        .emit("recording-state-changed", &new_state)
        .map_err(StateError::Emit)?;
    Ok(())
}

Listen in the frontend:

import { listen } from '@tauri-apps/api/event';

useEffect(() => {
  const unlisten = listen<RecordingState>('recording-state-changed', (event) => {
    setRecordingState(event.payload);
  });
  return () => { unlisten.then(fn => fn()); };
}, []);

Render based on state:

function RecordingPanel({ state }: { state: RecordingState }) {
  switch (state.variant) {
    case 'idle':
      return <StartButton />;
    case 'recording':
      return <RecordingIndicator startedAt={state.started_at} />;
    case 'processing':
      return <ProcessingSpinner duration={state.recording_duration_ms} />;
    case 'done':
      return <ResultView result={state.result} />;
    case 'error':
      return <ErrorBanner message={state.message} onDismiss={...} />;
  }
}

Notice: TypeScript’s exhaustive type checking means if you forget to handle one variant, the compiler complains. (Use as const or explicit never checks to enforce this in older TS configs.)

Suddenly the frontend can’t display impossible states either. Both sides agree.


What This Doesn’t Fix

  • Existing code still uses the old bools. Migration is incremental. Start with one feature path, prove the pattern, then expand.
  • State enums can grow too. If yours has more than ~7 variants, you have nested concerns. Split into multiple state machines (auth state, recording state, network state).
  • Transitions can still be wrong even if the type system is right. The transition function only enforces structural validity. Whether Recording → Processing is the right next move is still your logic.
  • Async transitions are tricky. If a transition starts in one process and finishes in another, both sides need to handle the intermediate state. Use a “pending” variant if needed.
  • Persistence requires care. Serializing a discriminated union to disk and re-loading it works fine, but you need to handle “I saved a state that v2 no longer recognizes” — migrate or fall back to Idle.

The console.log Audit

Here’s the concrete win we noticed: the boolean-flag version of our state had 194 console.log statements, many of them like:

console.log('🎤 starting recording', { isRecording, isProcessing, hasResult });
console.log('✅ recording stopped', { isRecording, isProcessing });
console.log('⏳ processing start', { isProcessing });
console.log('💾 saving result', { hasResult, isProcessing });

After the state machine refactor, ~150 of those could just be deleted. The state was visible everywhere — both processes received the typed enum on every transition. No more “which boolean is true right now?” debugging.

The remaining ~40 console.log calls were genuine domain logging (errors, performance counters). Those moved to tracing spans.

The metric: 194 console.logs → 0 console.logs, replaced with structured tracing events. The codebase got dramatically easier to reason about.


Try It This Week

Saturday morning (90 minutes):

  • Open your codebase and grep for boolean state variables
  • Make a list. You’ll probably have 8–15 of them.
  • Name the 4–7 valid states they collectively represent
  • Sketch the legal transitions between those states

Saturday afternoon (3 hours):

  • Implement the state enum in your backend language
  • Add the can_transition_to and transition functions
  • Replace ONE feature path (e.g., the recording flow) to use the enum
  • Delete the booleans that are now redundant

Sunday morning (2 hours):

  • Add the discriminated union type to your frontend
  • Wire up state-change events / IPC
  • Replace one component’s boolean-flag rendering with a switch (state.variant)

Sunday afternoon (1 hour):

  • Grep for console.log in the area you refactored
  • Count how many you can delete because the state is now visible structurally
  • Replace remaining ones with structured logging

This week:

  • Expand the pattern feature by feature
  • Don’t try to migrate everything at once — incremental works

That’s the migration. Expect ~2 days of focused work for a medium-sized codebase.


The Takeaway

Boolean flags grow combinatorially. Five boolean state variables = 32 possible state combinations. Most of those are invalid. Nothing in your code prevents reaching them. The result is a category of bugs that’s hard to debug because the bug is in the structure, not in any one line.

State machines fix this by changing what’s representable:

  • Enum variants = only the valid states exist
  • Variant data = state-specific data only available in its state
  • Transition function = only valid moves allowed
  • Shared types = frontend and backend both speak the same state language

The result is fewer bugs, less debugging, and a codebase you can reason about without holding 5 booleans in your head.

If your app has 5+ boolean state variables and you debug state confusion regularly: this is the weekend to fix it. Two days of focused work permanently eliminates a category of bugs and a category of debugging effort.

The compiler becomes your test suite. The state becomes your documentation. Your future self stops debugging “how did we get into this state?” because the state is now just data — small, named, and visible.


Coming up next: post #142 — Streaming transcription with whisper-rs callbacks. Once your state machine knows about “Processing,” the obvious follow-on is “what if the processing state could show live progress text?” Here’s the segment-callback pattern that turns batch transcription into live streaming.