State Machines in iOS: Turning “Navigation” Into a Testable Process
Most apps don’t have a navigation problem. They have a process problem.
“Which screen comes next?” is usually a side-effect of something deeper:
- Did we hydrate from disk?
- Are we authenticated?
- Is the network reachable?
- Did we complete onboarding?
- Did we satisfy required permissions?
- Did the backend acknowledge the last sync batch?
When that logic is scattered across view models, coordinators, and callbacks, you get the classic failure mode: the app usually works—until it doesn’t—and reproducing the bug becomes archaeology.
A state machine is the simplest way I know to make the process explicit, deterministic, and testable.
If you already use coordinators (UIKit or SwiftUI routing), a state machine typically sits one level above them:
- State machine: decides which flow is active and what transitions are allowed
- Coordinator/Router: executes navigation for that active flow
- UI: renders the current state and sends intents/events
This post shows a pragmatic pattern I use: a state machine with:
- pre-transition validation (can we move?)
- pre-transition hooks (do work before moving)
- post-transition hooks (cleanup / side-effects)
- an orchestrator that owns long-running logic and uses the machine as the source of truth
No “framework,” no ceremony—just something small that survives reality.
A quick war story (why this exists)
A common production bug looks like this:
- App launches and begins hydration
- A deep link arrives immediately and tries to route to a detail screen
- Another async task finishes slightly later and “wins,” pushing the app back to a default screen
- You get a flaky “sometimes deep links don’t work” report that’s hard to reproduce
This isn’t a UI problem. It’s a process problem: multiple async tasks are deciding “what happens next” without a single source of truth.
The goal: one source of truth for “where the app is”
I want exactly one place that can answer:
What state is the app in, and why?
This is not about replacing SwiftUI navigation. It’s about separating concerns:
- State machine: the truth table for allowed transitions
- Orchestrator: performs I/O and decides which events to send
- UI: reacts to state, triggers user intents, stays dumb
If you mix these, you get “UI drives the process,” which becomes fragile fast.
When a state machine is worth it
I reach for a state machine when:
- there are 5+ steps in a flow (onboarding, wallet creation, checkout, KYC, offline sync)
- steps have async work (network + disk)
- there are failure states that need clear recovery paths
- you need invariants (“you can’t be Ready if auth == false”)
When I don’t use it:
- a simple linear flow
- single-screen forms
- cases where a plain enum + straightforward branching is enough
If a state machine makes the code harder to read than the problem, don’t use it.
Design first: draw the flow (even a simple one)
Before code, I write the diagram / truth table. For a basic startup flow:
launching
└─(didLaunch)→ hydrating
├─(hydrateSucceeded: false)→ needsLogin
│ └─(loginRequested)→ authenticating
│ ├─(loginSucceeded)→ ready
│ └─(loginFailed)→ error
└─(hydrateSucceeded: true)→ ready (or validate token → ready/error)
error
└─(retry)→ hydratingThe point is not the diagram itself—it’s that the logic is explicit and centralized.
Why not just use GameplayKit’s
GKStateMachine
?
GKStateMachine is a legitimate option and the mental model is excellent:
- Each state decides what’s legal next (isValidNextState)
- Enter/exit lifecycle hooks exist (didEnter(from:), etc.)
Those map directly to the approach below:
- isValidNextState → validator
- didEnter / exit lifecycle → hooks
So why show an actor-based reducer machine instead?
- Many app flows want async work and cancellation to be first-class.
- I usually prefer value-typed state + event, with a truth-table reducer that’s trivial to test.
- I want the orchestration story to be explicit: the state machine stays deterministic; I/O happens elsewhere.
If you prefer GameplayKit, you can still apply the same architecture: keep states shallow, centralize side-effects, and test transitions like a truth table.
A concrete example: app startup → authenticated → ready
Let’s model a simplified startup flow:
- Launch
- Hydrate local session
- Ready (authenticated) or NeedsLogin
- Error (with retry)
State and event
enum AppState: Equatable {
case launching
case hydrating
case needsLogin
case authenticating
case ready(userID: String)
case error(message: String)
}
enum AppEvent: Equatable {
case didLaunch
case hydrateSucceeded(hasSession: Bool)
case loginRequested
case loginSucceeded(userID: String)
case loginFailed(message: String)
case retry
}Rules that keep this sane
This is the part that prevents state machines from becoming magical:
- Validators are pure. No I/O. No async. No side-effects.If you can’t unit test a validator without mocks, it’s not a validator.
- Hooks are boring. Logging, metrics, cancellation, teardown—cross-cutting concerns.
- Orchestrators own I/O. Network/disk belongs outside the state machine.
Adding validators + hooks (pre and post)
Protocol surface
protocol TransitionValidator {
associatedtype State
associatedtype Event
func canTransition(from: State, event: Event, to: State) -> Bool
}
protocol TransitionHook {
associatedtype State
associatedtype Event
func willTransition(from: State, event: Event, to: State) async throws
func didTransition(from: State, event: Event, to: State) async
}A minimal state machine implementation (Swift concurrency)
This implementation is intentionally small:
- it serializes events (avoids reentrancy bugs)
- it checks validation before moving
- it runs pre/post hooks
- it makes illegal transitions observable
import Foundation
enum TransitionError: Error {
case invalidTransition
case preHookFailed
}
actor StateMachine<State: Equatable, Event> {
typealias Reducer = (State, Event) -> State?
private var state: State
private let reducer: Reducer
private let validators: [any TransitionValidatorBox<State, Event>]
private let hooks: [any TransitionHookBox<State, Event>]
/// Policy: most apps should *ignore* unknown events (but log them).
/// Throwing is useful when a violation indicates a programming error.
private let throwsOnInvalid: Bool
init(
initial: State,
reducer: @escaping Reducer,
validators: [any TransitionValidatorBox<State, Event>] = [],
hooks: [any TransitionHookBox<State, Event>] = [],
throwsOnInvalid: Bool = false
) {
self.state = initial
self.reducer = reducer
self.validators = validators
self.hooks = hooks
self.throwsOnInvalid = throwsOnInvalid
}
func currentState() -> State { state }
func send(_ event: Event) async throws -> State {
guard let next = reducer(state, event) else {
if throwsOnInvalid { throw TransitionError.invalidTransition }
return state
}
for v in validators {
guard v.canTransition(from: state, event: event, to: next) else {
if throwsOnInvalid { throw TransitionError.invalidTransition }
return state
}
}
for h in hooks {
do { try await h.willTransition(from: state, event: event, to: next) }
catch {
if throwsOnInvalid { throw TransitionError.preHookFailed }
return state
}
}
let previous = state
state = next
for h in hooks {
await h.didTransition(from: previous, event: event, to: next)
}
return state
}
}Swift doesn’t allow any ProtocolWithAssociatedType directly in arrays without type erasure, so here are small “boxes”:
protocol TransitionValidatorBox<State, Event> {
func canTransition(from: State, event: Event, to: State) -> Bool
}
struct AnyTransitionValidator<State, Event>: TransitionValidatorBox {
private let _can: (State, Event, State) -> Bool
init(_ can: @escaping (State, Event, State) -> Bool) { self._can = can }
func canTransition(from: State, event: Event, to: State) -> Bool {
_can(from, event, to)
}
}
protocol TransitionHookBox<State, Event> {
func willTransition(from: State, event: Event, to: State) async throws
func didTransition(from: State, event: Event, to: State) async
}
struct AnyTransitionHook<State, Event>: TransitionHookBox {
private let _will: (State, Event, State) async throws -> Void
private let _did: (State, Event, State) async -> Void
init(
will: @escaping (State, Event, State) async throws -> Void = { _,_,_ in },
did: @escaping (State, Event, State) async -> Void = { _,_,_ in }
) {
self._will = will
self._did = did
}
func willTransition(from: State, event: Event, to: State) async throws { try await _will(from, event, to) }
func didTransition(from: State, event: Event, to: State) async { await _did(from, event, to) }
}Reducer for the startup flow (the truth table)
let reducer: (AppState, AppEvent) -> AppState? = { state, event in
switch (state, event) {
case (.launching, .didLaunch):
return .hydrating
case (.hydrating, .hydrateSucceeded(let hasSession)):
return hasSession ? .ready(userID: "unknown-yet") : .needsLogin
case (.needsLogin, .loginRequested):
return .authenticating
case (.authenticating, .loginSucceeded(let userID)):
return .ready(userID: userID)
case (.authenticating, .loginFailed(let message)):
return .error(message: message)
case (.error, .retry):
return .hydrating
default:
return nil
}
}Where the orchestrator fits (and why it matters)
A state machine defines what can happen. The orchestrator defines what should happen and when.
Example: on app launch, the orchestrator triggers hydration, then sends events based on results.
final class AppOrchestrator {
private let machine: StateMachine<AppState, AppEvent>
init(machine: StateMachine<AppState, AppEvent>) {
self.machine = machine
}
func start() {
Task {
do {
_ = try await machine.send(.didLaunch)
let hasSession = await loadSessionFromDisk()
_ = try await machine.send(.hydrateSucceeded(hasSession: hasSession))
} catch {
// Handle startup failure
print("Startup failed: \(error)")
}
}
}
private func loadSessionFromDisk() async -> Bool {
try? await Task.sleep(nanoseconds: 150_000_000)
return false
}
}This keeps UI simple:
- UI reads state and renders the screen for that state
- UI sends intents (“loginRequested”)
- orchestrator does I/O and sends results (“loginSucceeded”)
If you use coordinators, this is also where you decide which coordinator is active, and you keep dependencies centralized (router/presenter, storage/session, etc.).
Why pre/post hooks are useful (without abusing them)
I use hooks for cross-cutting concerns that should not be scattered:
- logging, analytics, metrics
- timing (“time spent in state X”)
- cancellation and teardown on exit (stop tasks, release child coordinators)
- starting/stopping background loops (like periodic sync)
Example hook: log transitions.
let loggingHook = AnyTransitionHook<AppState, AppEvent>(
will: { from, event, to in
// log("will: \(from) --\(event)--> \(to)")
},
did: { from, event, to in
// log("did: \(from) --\(event)--> \(to)")
}
)What I avoid: putting business logic into hooks. Hooks should be shallow and boring.
Testing: the real reason this pattern pays off
This is why it’s senior: you can unit test the process like a truth table.
- Given state X and event Y, assert next state Z
- Assert illegal transitions are ignored (or throw)
- Assert validators block transitions
- Assert orchestrator emits events in order (with test fakes)
Even if the UI changes, your process remains stable.
Startup-scale vs big-org scale (what changes)
The pattern stays the same; the constraints change.
In a startup / small team, I keep it lean:
- fewer states, fewer abstractions
- ignore invalid transitions + log (don’t crash users)
- focus on a handful of tests that catch races and regressions
In a larger org / regulated environment, I get stricter:
- stronger invariants, more exhaustive transition coverage
- tighter observability (transition auditing, metrics)
- clearer separation of responsibilities across modules
Same model—different rigor, based on risk and change velocity.
The takeaway
If your flow is more than a couple steps and has async work, treat it like a process:
- put the truth table in one place (state machine / reducer)
- put side-effects in one place (orchestrator)
- keep UI reactive, not “in charge”
You’ll ship fewer “impossible” bugs and you’ll spend less time debugging invisible coupling across screens.