💡 Read Part 1 first to understand the concept. This post shows you exactly how to implement it.

What We’re Building

A simple 3-screen onboarding flow:

  1. Account Loader Screen → Loads the user’s account in a loader screen
  2. Welcome Screen → user taps “Get Started”
  3. Device Identification Screen → user enters their devices CMMac

Let’s build this with state-driven navigation, step by step.

1 - States

States represent every screen your user can be on:

enum OnboardingState {
    case accountLoader
    case welcome
    case deviceIdentification
    case performDeviceChecks
}

Three screens = three states.

2 - Events

Events are things that happen in your app:

enum OnboardingEvent {
    case accountLoaderFinished
    case startFlowTapped
    case deviceWasIdentified
}

User tapped a button? That’s an event. Network call (successfully) finished? That’s an event.

Your events aren’t necessarily network call finished. It’s more of “Is the screen tied to this state done?”. You could technically have multiple network calls that need finishing, so your state only changes when the screen is done

3 - State Machine

State machine is responsible for:

  1. Hold the current state
  2. Process events and update state
class OnboardingStateMachine: ObservableObject {
    @Published private(set) var currentState: OnboardingState = .accountLoader
    
    func handleEvent(_ event: OnboardingEvent) {
        switch (currentState, event) {
        
        case (.accountLoader, .accountLoaderFinished):
            currentState = .welcome
            
        case (.welcome, .startFlowTapped):
            currentState = .deviceIdentification
            
        case (.deviceIdentification, .deviceWasIdentified):
            // this state isn't handled in this post. 
            currentState = .performDeviceChecks
            
        default:
            print("⚠️ Invalid event \(event) for state \(currentState)")
        }
    }
}

💡 Only the state machine can change currentState. It’s private(set). Everyone else just reads it.

4 - Navigation Layer

The navigator is subscribed to the state machine and shows the right view:

struct OnboardingNavigator: View {
    @ObservedObject var stateMachine: OnboardingStateMachine
    
    var body: some View {
        ZStack {
            switch stateMachine.currentState {
            case .accountLoader:
                AccountLoaderView(stateMachine: stateMachine)
                    .transition(.opacity)
                
            case .welcome:
                WelcomeView(stateMachine: stateMachine)
                    .transition(.move(edge: .trailing))
                
            case .deviceIdentification:
                DeviceIdentification(stateMachine: stateMachine)
                    .transition(.move(edge: .trailing))
            }
        }
        .animation(.easeInOut, value: stateMachine.currentState)
    }
}

The navigator doesn’t decide what to show. It just reacts to state changes. That’s the magic.

5 - Views

Views are dumb. They just:

  1. Display UI
  2. Send events when things happen

AccountLoaderView:

struct AccountLoaderView: View {
    let stateMachine: OnboardingStateMachine
    
    var body: some View {
        VStack {           
            Text("Loader")
        }
        .task {
            // Simulate loading
            await getAccount()
            stateMachine.handleEvent(.accountLoaderFinished)
        }
    }
}

WelcomeView:

struct WelcomeView: View {
    let stateMachine: OnboardingStateMachine
    
    var body: some View {
        VStack {
            Text("Welcome!")            
            Button("Start Flow") {
                stateMachine.handleEvent(.startFlowTapped)
            }
        }
    }
}

💡 Notice: Views never navigate directly. They just send events. The state machine decides what happens next.

Zero direct navigation. Zero coupling. Each piece does one job.

You just have to initialize the state machine with that state:

let stateMachine = OnboardingStateMachine()
stateMachine.currentState = .deviceIdentification  // Start at login

This would be really difficult with a traditional view to view architecture.

Testing

Each piece can be tested in complete isolation:

Test State Machine:

func testAccountLoaderToWelcomeScreen() {
    let stateMachine = OnboardingStateMachine()
    stateMachine.currentState = .accountLoader
    
    stateMachine.handleEvent(.accountLoaderFinished)
    
    XCTAssertEqual(stateMachine.currentState, .welcome)
}

Test View Logic:

func testWelcomeViewSendsEvent() {
    let stateMachine = MockStateMachine()
    let view = WelcomeView(stateMachine: stateMachine)
    
    // Simulate button tap
    view.getStartedButton.tap()
    
    XCTAssertTrue(stateMachine.receivedEvent(.startFlowTapped))
}

No need to actually navigate. Just verify events are sent and state changes correctly.

How to make this more real?

This post is intentionally simple. Real apps add:

  • ViewModels for business logic
  • Async operations (network calls, etc.)
  • Conditional flows (different paths for different users tiers)
  • Errors (.loading, .error(message))

But the pattern stays the same: Events → State Machine → Navigation Layer → Views.

💡 Errors are often best handled within the viewModels. Example, errors within AccountLoader state, should just get handled within the AccountLoaderViewModel, unless it’s something that effects the entire app state like:

  • access token being revoked and getting logged out
  • network connectivity being down.
  • servers being down.

Shout outs

Shout outs to Jack Wright for helping me figure out a lot of nuances about State Machines and how to build this at scale.