Benefits
Following SOLID principles, if you have a navigation object that is solely driven by a state machine and does nothing but navigation then you gain the following benefits:
- Developers get to read a class and readily get an x-ray view of the codebases’s architecture and user navigation. Be like “for a given state, based on user’s account information and user interaction, I know which view to push to”. Example: for state:
OnboardingLoader, pushOnboardingView(user: user)| for state . - You can change navigation transitions from a centralized location.
- You can easily unit-test your navigation logic.
- Processing deeplinks becomes significantly easier. You just say set your start state to a different state. Example start from
stateDinstead of starting fromstateA. - Decoupling views and viewmodels from navigation logic, allows your views and viewmodels to be leaner, simpler and more focused. They have less ownership and logic which in return makes their testing easier.
- To expand on this, a simple app can be split into:
- Views
- ViewModels
- Utilities
- App state machine
- Navigation layer
High Level
Example:
Foo app
// StateMachine.swift
State: OnboardingLoader
Event: OnboardingLoaderFinished -> return command: NavigateToAccountSetup & set (new) state: AccountSetup
parallel to this:
// Navigation.swift
Navigation reacts to state being changed to AccountSetup + already has user's account info -> push to: standard account view
Note: Navigation layer is subscribed to app’s state machine, and for every state change, it knows which view to push.
All state is read-only outside the source of truth (app state machine). Any change to state is done by sending events to the state machine. The state machine processes the event, updates the state, and emits commands (like navigation to screenX) that other layers (like navigation layer) can react to.
To discuss about the pros and cons of different approaches to do navigation from a viewModel:
push(to: ViewX())
- Needs to know too much to create views
- May need to do if-else based on user type, app state, etc.
- Would own navigation logic. This makes us move away from single responsibility principle.
- App state is scattered across viewModels. This makes state correctness hard. Other components can’t easily react to state changes.
- Testability is muddied. The view owns state and navigation logic.
- Architecture: No State Machine. Easy to begin with; hard to maintain and test
stateMachine.set(to: .stateX)
- Needs to know a bit about what’s next and the views after it.
- May need to do if-else based on user type, app state, etc.
- Wouldn’t own navigation logic. Yet it owns state management logic which isn’t good. This makes us move away from single responsibility principle.
- App State is owned by the state machine, but since viewModels are setting states directly, state correctness is at risk.
- Testability is cleaner, however the view owns state logic.
- Architecture: Medium setup; state management logic leaks into viewModels
stateMachine.handleEvent(.eventR)
- Doesn’t need to know anything about what comes after. Just sends an event to the state machine.
- Doesn’t need to know about user type, app state, etc. It just sends an event and is done.
- Wouldn’t own navigation logic nor state state management logic. This is great.
- App State is entirely owned by state machine. This is great for state correctness.
- Testability is easy. ViewModels, State Machine and Navigation Layer can be tested in isolation.
- Architecture: Medium setup; predictable, testable states with navigation logic separated
The extra benefit of third approach is that it’s possible that eventR can result in either stateX or stateY depending on say user type (basic vs premium).
💡 Passing events to the state machine follows the UDF (Unidrectional Data Flow) principle. It ensures there’s only one (very controlled) way of doing each thing i.e. you’re not allowed to change the state from anywhere and everywhere. Nor you’re allowed to navigate from anywhere and everywhere.
Common pitfalls
- A view not relying on the app state machine for its state nor relying on the Navigation object to manage navigation. This can lead to inconsistencies between the UI and the app’s state.
- A view not sending events to the state machine when user interacts with it. This breaks the unidirectional data flow and can lead to hard-to-debug issues.
- A View directly changing app state instead of sending an event to the state machine. The freedom removes control which can lead to state inconsistencies.
Shout out
Special shout out to Tom Insam for helping me understand these concepts better and be able to articulate them.