6 min read

Common Redux Pitfalls on iOS: Deep Navigation Stack

Redux is often suggested as a silver bullet for mobile app architecture. However, it also brings noticeable inconveniences that are worth mentioning. One of them kicks in when we start to deal with a deep navigation stack.
Common Redux Pitfalls on iOS: Deep Navigation Stack
Photo by Martin Sanchez / Unsplash

Now it's almost 2 years since the legendary post about Redux on iOS

Since then Redux becase a little bit more popular on iOS. Sometimes it's even suggested as a silver bullet for mobile app architecture. However, it also brings noticeable inconveniences that are worth mentioning.

One of them kicks in when we start to deal with a deep navigation stack.

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:

  1. Proper data structure holding multiple screen states.
  2. How to deliver actions to the appropriate reducer of the appropriate state
  3. 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.

Why There Is No 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()
        }
    }
	
}

Why The 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() {
            dispatch(Actions.FancyActions.StartFlow(id: screenId))
	}

	func viewWillDisappear() {
	    dispatch(Actions.FancyActions.FinishFlow(id: screenId))
	}
}

or SwiftUI kind of things:

struct FancyView: View {
    let screenId: ScreenID
    
    var body: some View {
    	FancyBodyViewHere(
        	...
        )
        .onAppear {
            dispatch(Actions.FancyActions.StartFlow(id: screenId))
        }
        .onDisappear {
            dispatch(Actions.FancyActions.FinishFlow(id: screenId))
        }
    }
}

And then mutate the app state in the reducer:

struct AppState {
    var screenStates: [ScreenID: FancyScreenState] = [:]
}

extension AppState {

    mutating func reduce(action: Action) {
        switch action {
        case let action as Actions.FancyActions.StartFlow:
            screenStates[action.id] = FancyScreenState(id: action.id)    
        case let action as Actions.FancyActions.FinishFlow:
            screenStates[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.

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.