Taming Multi-Stage Hardware Setup: State Machines in Flutter
Hardware setup flows are deceptively hard to build well. A Wi-Fi speaker or IoT device might need Bluetooth permissions, adapter readiness, a 10-second scan, device selection, GATT connection, and a service handshake - each of which can fail independently and silently. When you are building this in Flutter, you quickly discover that the problem is not the BLE protocol. It is the state.
Eight booleans. That is what you end up with if you do not design for this upfront. And those eight booleans, each toggled at different points in an async chain, will eventually reach a combination that your UI has no rendering path for. This is the story of how we built the device setup flow for a smart IoT product, what went wrong with naive approaches, and how a clearer state model made the difference.
The Boolean Trap
The instinct when building a multi-step setup flow is to reach for boolean flags. One flag for scanning, one for found, one for pairing - it reads naturally and maps directly to what the UI needs to render.
Here is roughly what this looks like in practice:
bool flagA = false;
bool flagB = false;
bool flagC = false;
bool flagD = false;
bool flagE = false;
bool flagF = false;
bool flagG = false;
bool flagH = false;
Eight flags. Each is independently settable. Which means you now have 2⁸ = 256 theoretical combinations, and your UI has to handle all of them - or silently render garbage for the ones you forgot.
The specific failure mode is subtle. In an async BLE flow, a scan timeout fires and sets flagA = false and flagG = true. But if the user tapped a device just before the timeout (a race condition that is not hypothetical - it happens regularly on Android), you now have flagC = true and flagG = true simultaneously. The UI renders a retry button next to a selected device. The user is confused. Your error logs show a pairing attempt fired from the retry path.
This is not a bug in any single function. It is the fundamental problem with representing state as a bag of booleans: you cannot enforce mutual exclusivity, and async operations on separate Futures will violate your assumptions without warning.
The State Space Problem
Boolean flags: 8 flags = 256 possible combinations
Valid states in the flow: ~10
States your UI actually handles: ???
┌─────────────────────────────────────────┐
│ Possible Combinations │
│ 256 │
│ ┌──────────────────────────────────┐ │
│ │ Intended States (~10) │ │
│ │ idle → waiting → scanning → │ │
│ │ found → selected → pairing → │ │
│ │ connected | retry states │ │
│ └──────────────────────────────────┘ │
│ │
│ Unhandled combinations = silent bugs │
└─────────────────────────────────────────┘
The gap between the state space your model allows and the states your UI handles is where silent bugs live.
The Alternatives and Their Pitfalls
Before settling on our approach, we evaluated three patterns.
Sealed Class / Enum State
The theoretically clean solution. Model your states as a sealed class:
sealed class SetupState {}
class StateA extends SetupState {}
class StateB extends SetupState {}
class StateC extends SetupState {
final List<HardwareDevice> items;
StateC(this.items);
}
class StateD extends SetupState {}
class StateE extends SetupState {}
The UI switches on this single value. No illegal combinations. The compiler catches missing cases in switch expressions.
The pitfall: State transitions become verbose. When you are mid-scan and a device is found, you transition from StateB into StateC - but StateC needs to carry the accumulated device list. This means either bloating the state class or maintaining parallel mutable lists that live outside the state object. You end up with the same mutation problem, just hidden one level deeper.
Retry states are also awkward. A retry is not a state - it is a condition that can overlay almost any state. Modeling it as a peer state loses the context of where in the flow the retry was triggered from.
BLoC / Event-Driven
BLoC solves the illegal-state problem well. Events are explicit, transitions are pure functions, and the state at any point is the output of folding over the event stream. For a linear flow this works cleanly.
The pitfall: BLE setup is not linear. Scan results arrive continuously via a Stream<List<ScanResult>>. The BLoC pattern wants you to dispatch an event per scan result, creating a burst of events during active scanning. Each ItemFoundEvent rebuilds your state object with a new copy of the device list. On a busy scan (common in public spaces), this hammers rebuilds and creates perceptible jank on mid-range Android devices.
BLoC's navigation story is also poor. You need side-effect streams, BlocListener, and careful coordination with the Navigator - all for a flow where navigation is not a user choice but a hardware event consequence.
ValueNotifier / ChangeNotifier Directly
Lightweight and Flutter-native. But it gives you no structure for grouping state or managing the lifecycle of async operations. You quickly end up with a StatefulWidget managing a dozen ValueNotifiers, with a dispose() that grows longer than the build method.
What We Actually Did: Structured Booleans with Explicit Contracts
We kept the boolean flags - but imposed discipline on how they are mutated. Every transition goes through a method that resets conflicting flags as part of the entry:
Future<void> beginStep() async {
// Entering this step: clear all competing states
flagB = false;
flagA = true;
flagG = false;
flagF = false;
itemList.clear();
selectedIndex = null;
flagC = false;
notifyListeners();
// ... rest of step logic
}
This is not a state machine in the formal sense. But it is a disciplined boolean model - every entry point into a state is responsible for exiting all incompatible states. The transitions are the source of truth, not the flags themselves.
The key insight: the UI's rendering contract is defined by priority, not combination. The body builder checks states in strict order - retry conditions first, then active states, then idle. Even if two booleans are simultaneously true due to a race, the higher-priority check wins and the UI renders consistently.
State Transition Flow

Each failure path sets exactly one retry flag and halts the current chain. No state is left partially active.
The Navigation Side-Effect Problem
The harder problem with hardware setup flows is navigation. When a connection succeeds, you do not want the user to press "Next." You want the app to move them, because the hardware event is the trigger, not a user action. This means your ViewModel needs to call Navigator.pushReplacement from inside an async callback.
This is where context safety becomes critical:
void _proceedToNext(BuildContext context) {
flagD = false;
notifyListeners();
if (activeItem == null) return; // race: widget disposed mid-connect
if (!context.mounted) return; // guard against stale context
Navigator.pushReplacement(context, MaterialPageRoute(...));
}
The context.mounted check is not optional. A connection can take up to 15 seconds. In that window, the user might back out, the OS might interrupt, or the widget might be disposed for an unrelated reason. Without this guard, you push into a dead navigator and crash.
A BaseViewModel does not solve this for you - but it does give you dispose() as a canonical cleanup point where you cancel scan subscriptions and disconnect the device, preventing callbacks from firing into an already-disposed model.
iOS vs Android: Where the Model Breaks Differently

One thing no pattern handles for you: permission and adapter readiness checks are platform-divergent in ways that break the expected flow order.
On Android, you can call Scanner.enable() and wait on the adapterState stream for the on event. On iOS, enable() does nothing useful beyond showing a dialog, and the user must manually go to Settings. The adapter state stream on iOS does not reliably emit after the user returns.
This means _checkHardwareReady() branches early by platform:
if (Platform.isIOS) {
// enable() is a no-op beyond showing a dialog. Bail and show retry.
flagF = true;
return false;
}
// Android: wait on stream with timeout
await Scanner.adapterState
.where((s) => s == AdapterState.on)
.timeout(const Duration(seconds: 15));
A formal state machine does not save you here. The states are identical; the transition logic diverges by platform. Whatever abstraction you choose, this branching lives in the transition code, not the state model itself. Accepting this explicitly, rather than trying to unify it behind an abstraction - leads to code that is easier to debug when something goes wrong on only one platform.
Takeaways
Illegal state is a design problem, not a bug. Boolean flags do not prevent invalid combinations, only explicit transition methods with cleanup responsibilities do.
Retry is an overlay, not a peer state. Modeling retry as a sealed class variant forces awkward branching. Keeping it as a flag with clear render priority is more honest about what it actually represents.
Context safety in async hardware flows is non-negotiable. The gap between a connection attempt starting and completing is wide enough for the widget tree to change entirely. Guard every navigation call.
Platform divergence lives in transitions, not states. No state machine abstraction eliminates the iOS/Android split. Accept it explicitly rather than trying to unify it.
Log failures to an observability channel. In our flow, every failure path logs a structured event externally. This turns silent edge cases into visible signals. Hardware setup failures are the hardest bugs to reproduce locally, you cannot fix what you cannot see.