Common Redux Pitfalls on iOS: Deep Navigation Stack
#iOS App Development
Deep Navigation Flow Example
Let's assume we have a common user profile flow. Having the navigation consisting of: Users list -> User's detail -> User follower's list -> User's detail ->, etc.
It's a rather common navigation pattern that we often meet in social network apps.
The navigation flow can go very deep. So eventually we might find several user list screens instances in the navigation stack at the same time.
Problem overview
This automatically leads to several questions that come from the Redux's main idea about single-state design:
- Proper data structure holding multiple screen states.
- How to deliver actions to the appropriate reducer of the appropriate state
- How to manage each screen's state lifecycle.
State Design
App's state should keep multiple "user list" screen states. Each screen state would have its own list of users and is likely to have its own pagination setup so it should keep its cursor, handle loading states, etc.
Actions Delivery
For each screen, we should perform the proper network requests depending on the screen's, let's say "config".
For eg, it can be a list of
- Followers of User X,
- Followed by User Y.
- Friends of User Z
- Any other list of users.
When actions are performed they should be delivered to the proper pair of (state, reducer). So that the proper screen would receive the proper list of users.
State lifecycle
Finally, we need to know when to create and destroy each screen's state.
There is no such problem without Redux
Without Redux it wouldn't even be a problem at all.
We can create a screen state (or view model or presenter, whatever you call it) as a UIViewController
or SwiftUI's View
property, ask to fetch data when needed and deallocate it together with a screen:
final class FancyViewController: UIViewController {
private var viewModel = FancyScreenViewModel()
func viewDidLoad() {
viewModel.fetchData()
}
}
or something like
struct FancyView: View {
private @State var viewModel = FancyScreenViewModel()
var body: some View {
SomeBodyView(...).onAppear {
viewModel.fetchData()
}
}
}
Problem Comes Exclusively with Redux
With Redux it becomes a bit cumbersome because the Redux state is kept separately from the UI level. App's state has to be a data structure holding multiple screen states.
We have to make sure that actions generated on a particular screen, are delivered to the right state and reducer.
We have to handle some particular actions to create and destroy the screen state.
All has to be done manually.
State Design For Deep Navigation Stack Options
Option 1: Flat Key-Value Storage for screens states
Just like we typically do with plain Entity models, we can assign each screen some kind of ScreenID
and keep screen states in key-value dictionaries.
In other words, in the case of deep navigation we are moving from a simple state structure like this:
struct AppState {
var screenStateA = ScreenStateA()
var screenStateB = ScreenStateB()
var screenStateC = ScreenStateC()
}
To this:
struct AppState {
var screenStatesA: [ScreenID: ScreenStateA] = [:]
var screenStatesB: [ScreenID: ScreenStateB] = [:]
var screenStatesC: [ScreenID: ScreenStateC] = [:]
}
It's quite an affordable way to deal with screen states. Each screen connects to its particular state via ID
and that's it. Just like we sometimes do it with screens attached to entities. For eg, some kind of UserScreen
for User
with some ID
.
The only inconvenience of the screens dictionary is handling optional values when getting values from the dictionary. However, it can be solved gracefully, by providing a "loading" or a "zero-state" screen UI.
Option 2: Mimic Navigation Structure
Another approach to deep navigation state design is to make the shape of the state mimic the navigation structure by nesting the screen states.
I imagine this could be achieved with indirect enums
like:
typealias ScreenID = UUID
indirect enum NavigationState<T> {
case none
case presented(screen: T)
var screen: T? {
switch self {
case .none:
return nil
case .presented(let screenState):
return screenState
}
}
}
struct User {
let id: Int
}
struct ListScreenState {
let id = ScreenID()
let users: [User]
var details: NavigationState<DetailScreenState> = .none
}
struct DetailScreenState {
let id = ScreenID()
let user: User
var list: NavigationState<ListScreenState> = .none
}
It looks interesting.
The upside of this is that we can easily build 100% state-driven navigation. This could possibly work cool with SwiftUI in iOS13 - iOS15. Yeah, buggy, but gracefully.
Unfortunately, the state-driven navigation stack has been deprecated with NavigationView
recently and it doesn't feel like an encouraged way to go anymore.
For me personally, the recursive structure of the state with deep navigation seems like an over-complication. I would prefer it something more straightforward.
Actions Delivery Options
Common Redux design implies that actions are dispatched to every reducer allowing us to mutate any part of the state. It gives a lot of power but also a lot of responsibility.
If we have to deal with a deep navigation stack of similar screens, like in the example. users list -> user details -> users lists -> user details
, we are likely to deal with multiple instances of screen state of the same type with corresponding reducers.
So, when actions are dispatched we need to somehow guarantee that the action would be delivered to the correct pair of screen state + reducer, and mutate the proper part of the state.
We can play with that in many ways.
Filter Actions in Screen State's Reducer
We can introduce a ScreenID
and filter out actions on the screen state reducer's level:
protocol Action {
}
struct ListScreenState {
let id = ScreenID()
private(set) var isLoading = false
private(set) users: [User]
}
enum Actions {
struct LoadMore: Action {
let screenId: ScreenID
}
}
extension ListScreenState {
mutating func reduce(action: Action) {
switch action {
case let action as LoadMore:
guard action.screenId == id else { return }
isLoading = true
default:
break
}
}
}
Then just reduce each screen's state in higher level state's reducer:
struct ScreenStateA {
mutating func reduce(_ action: Action) { ... }
}
struct ScreenStateB {
mutating func reduce(_ action: Action) { ... }
}
struct AppState {
var screenStatesA: [ScreenID: ScreenStateA] = [:]
var screenStatesB: [ScreenID: ScreenStateB] = [:]
}
extension AppState {
mutating func reduce(action: Action) {
screenStatesA = screenStatesA.mapValues {
var state = $0
state.reduce(action)
return state
}
screenStatesB = screenStatesB.mapValues {
var state = $0
state.reduce(action)
return state
}
}
}
Filter Actions in Reducer at Higher Level
We may want to move the filter by source id upper to the higher level reducer.
protocol ScreenAction: Action {
var screenId: ScreenID { get }
}
struct ScreenStateA {
mutating func reduce(_ action: Action) { }
}
struct ScreenStateB {
mutating func reduce(_ action: Action) { }
}
struct AppState {
var screenStatesA: [ScreenID: ScreenStateA] = [:]
var screenStatesB: [ScreenID: ScreenStateB] = [:]
}
extension AppState {
mutating func reduce(action: Action) {
switch action {
case let action as ScreenAction:
if var state = screenStatesA[action.screenId] {
state.reduce(action)
screenStatesA[action.screenId] = state
}
if var state = screenStatesB[action.screenId] {
state.reduce(action)
screenStatesB[action.screenId] = state
}
default:
break
}
}
}
Legit? Well, more or less. But still.
To me, it doesn't feel natural and smells more like an error-prone hack around than an elegant solution and a way to go.
Screen State lifecycle
Since on Redux the screen's state is kept separately for the screen instance itself, it's not allocated/deallocated together with the screen instance.
That's a problem.
We have to manually create/delete appropriate screen states when the screen is presented/dismissed.
Possible solution for handling screen state lifecycle
To achieve that we can dispatch lifecycle actions for the screen like this:
protocol Action {
}
enum Actions {
enum FancyActions {
struct StartFlow: Action {
let id: ScreenID
}
struct FinishFlow: Action {
let id: ScreenID
}
}
}
final class FancyViewController: UIViewController {
let screenId = ScreenID()
func viewDidLoad() {
store.dispatch(Actions.FancyActions.StartFlow(id: screenId))
}
func viewWillDisappear() {
store.dispatch(Actions.FancyActions.FinishFlow(id: screenId))
}
}
or SwiftUI kind of things:
struct FancyView: View {
let screenId: ScreenID
var body: some View {
FancyBodyViewHere(
...
)
.onAppear {
store.dispatch(Actions.FancyActions.StartFlow(id: screenId))
}
.onDisappear {
store.dispatch(Actions.FancyActions.FinishFlow(id: screenId))
}
}
}
And then mutate the app state in the reducer:
struct AppState {
var fancyScreenStates: [ScreenID: FancyScreenState] = [:]
}
extension AppState {
mutating func reduce(action: Action) {
switch action {
case let action as Actions.FancyActions.StartFlow:
fancyScreenStates[action.id] = FancyScreenState(id: action.id)
case let action as Actions.FancyActions.FinishFlow:
fancyScreenStates[action.id] = nil
default:
break
}
}
}
It doesn't look complicated. Furthermore, it opens interesting state persisting and caching opportunities for screens.
Nevertheless, to me, it still feels error-prone.
It's fairly easy to forget to dispatch an appropriate action to remove the state or forget to handle this action in the reducer.
Both will end up with a screen's state staying in the screens states storage forever.
Instead of Conclusion
Redux at least with its core idea of a single store and single state brings powerful capabilities.
However, in some cases, it brings extra headache that doesn't have an obvious and elegant solution. Looking ahead, there are much more elegant solutions for the problem mentioned above, but they are lying outside of the Redux territory.
Comments