💡 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:
- Account Loader Screen → Loads the user’s account in a loader screen
- Welcome Screen → user taps “Get Started”
- 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:
- Hold the current state
- 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’sprivate(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:
- Display UI
- 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.
Deeplinks
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
AccountLoaderstate, should just get handled within theAccountLoaderViewModel, 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.