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.

View to View Navigation

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

The Solution: State Machine + Navigation Layer

Instead of views directly navigating to other views, separate concerns into three layers:

  1. State Machine: Single source of truth for app state
  2. Navigation Layer: Observes state changes and handles all navigation
  3. Views/ViewModels: Send events to state machine, display UI based on state

State Machine Based Navigation

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: stateD instead of stateA
  • Conditional flows: One event (eventR) can lead to different states (stateX or stateY) 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.

References