State Machines in iOS: Turning “Navigation” Into a Testable Process

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:

  1. App launches and begins hydration
  2. A deep link arrives immediately and tries to route to a detail screen
  3. Another async task finishes slightly later and “wins,” pushing the app back to a default screen
  4. 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)→ hydrating

The 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:

  1. Launch
  2. Hydrate local session
  3. Ready (authenticated) or NeedsLogin
  4. 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.


Repo placement

GitHub - aylwing-all-win/StateMachineDemo
Contribute to aylwing-all-win/StateMachineDemo development by creating an account on GitHub.

Read more