State Machines Beat Boolean Flags: The Rust Enum Pattern That Killed 194 console.logs
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.logstatements 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
- Enumerate the valid states — usually 4–6 for a typical app (10 min)
- Replace bools with an enum — one source of truth (1–2 hours)
- Define legal transitions — explicit function that gates state changes (1–2 hours)
- 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
isRecordingANDhasResultare 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:
-
Each variant carries its relevant data.
Recordingcarries the start time and device.Donecarries 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 theIdlestate because the result isn’t there. -
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 };
Step 3: Define Legal Transitions (1–2 hours)
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 → Processingis 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_toandtransitionfunctions - 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.login 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.