Intro
What triggered the writing of this post were two things:
1. Prior Experience with State Machine
We’ve been using state machine for the past few years to simplify a complex onboarding flow. The complexity of our feature rises from:
- having too many conditions that determine the correct state
- having the conditions scattered.
Some conditions are known at the time of app launch, some are known at the time of onboarding flow commencing, but some others are only known after user identifies their device to us. This adds further complexity to our flow.
When you’re onboarding devices, the conditions could be:
- Is the device owned or being rented?
- Is the a premium device or basic?
- How many devices are being onboarded?
- Is this re-onboarding an already onboarded device? (You can’t tell until user identifies their CMMac)
- Is this replacing a previously onboarded device?
- What equipment is hard-wired at this location? Can they onboard their device at this location?
- What instructions should we show to the user for this particular device at this particular location?
- How did the user identify their device to us?
- Is this user upgrading or downgrading their tier or keeping it as-is?
- Is this onboarding flow supported by the app version?
- Is this device currently owned by or associated to another account?
- Has the user paid whatever it is needed to pay for this tier of service? Or is the account is in bad state?
- When the onboarding flow fails, how is the trobleshooting of the flow different per user type, device type, etc.
2. Reading a Book
Recently I was reading the highly recommended book: Mobile System Design - Manual Vicente Vivo (ByteByteGo). The book and its diagrams have given me a much better foundation on how SOLID principles, UDF, Navigation Layer and State Machine all work together to achieve a more scablable code-base.
Now let’s see how traditional navigation can potentially become challenging:
The Problem: Direct View-to-View Navigation
Most apps start with views directly pushing to other views: push(to: ViewB()). This seems simple at first, but quickly becomes a maintenance nightmare.
Cons of Direct Navigation
- Tight coupling: ViewA must know how to create ViewB instance, including all its dependencies
- Scattered state: App state is spread across multiple viewModels, making state correctness nearly impossible
- Single responsibility violation: Each view handles both its own logic AND navigation logic
- Difficult testing: Views own both state and navigation, making isolated unit tests extremely hard
- Deeplink hell: No centralized place to handle deeplinks—logic gets duplicated everywhere
- Hard to understand flows: Navigation paths are scattered across the codebase. Understanding user journeys requires hunting through multiple files
Nor there’s any quick way to figure out: “After identifiying a premium rented device, which instruction (screen) does a premium account first see?”
❕ Unless I know the name of that exact swift file, then I have to trace the flow from its first screen, then look at all the conditions through all the view code i.e. find all the button taps, network callbacks and see which one of them navigates to second screen. And then do the same tracking from the second screen and so on, until I find the first instruction screen.
The Solution: State Machine + Navigation Layer
💡 With a state maching approach, I can quickly just go to statemachine.swift, find the state, see what are all possible outgoing events from a given state or find all prior states of a given state, then look at my navigation layer and see the view that is associated with that state and find an answer.
I could also quickly visualize the state, navigation from the very first state all the way to the last state, along with their branching within just two files as opposed to having the need to look through every view/viewModel of every screen in the flow.
With this approach, instead of views directly navigating to other views, we separate concerns into three layers:
- State Machine: Single source of truth for app state
- Navigation Layer: Observes state changes and handles all navigation
- Views/ViewModels: Send events to state machine, display UI based on state
All state is read-only outside the state machine. Changes happen only by sending events. The state machine processes events, updates state, and emits commands that the navigation layer reacts to.
Pros of This Approach
- Centralized navigation: Read one file to understand all possible navigation paths—an x-ray view of your app’s architecture
- Decoupled views: Views don’t know what comes next. They just send events and display state
- Single source of truth: App state lives in one place, ensuring correctness
- Easy testing: Test ViewModels, State Machine, and Navigation Layer in complete isolation
- Trivial deeplinks: Start from any state:
stateDinstead ofstateA - Conditional flows: One event (
eventR) can lead to different states (stateXorstateY) based on user type (basic vs premium) - Simple changes: Modify navigation transitions in one centralized location
💡 This follows Unidirectional Data Flow (UDF): events flow in one direction only. State can’t be changed from anywhere—only through controlled events to the state machine. Navigation can’t be triggered from anywhere—only through the navigation layer reacting to state.
Common Pitfalls
- Bypassing the state machine: Views directly changing app state instead of sending events breaks the single source of truth
- Direct navigation: Views calling navigation methods directly instead of letting the navigation layer react to state changes
- Missing events: Views not sending events on user interactions breaks the unidirectional flow
Example
For a high level example see Part 2 - State Driven Navigation: Tutorial
Shout out
Special shout out to Tom Insam for helping me understand these concepts better and be able to articulate them.