ViewWithStore + TabView renders excessively

When there's a change in state in one tab another tab will also render if precautions isn't made.

Example project

Open a new xcode project and replace ContentView.swift with the following

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    var body: some View {
        let initialState = MainState(
            health: .init(age: 30),
            settings: .init(nickName: "tarball")
        )
        
        let store = Store(
            initialState: initialState,
            reducer: mainReducer,
            environment: MainEnvironment()
        )
        
        return MainView(store: store)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}


// MARK: - Health View

struct HealthState: Equatable {
    var age: Int?
    var ageString: String {
        if let age = age {
            return String(age)
        }
        return "unknown"
    }
}

enum HealthAction: Equatable {
    case loadAge
    case decreaseAge
    case increaseAge
}

struct HealthEnvironment {}

var healthReducer = Reducer<HealthState, HealthAction, HealthEnvironment>.init() { state, action, env in
    switch action {
    case .loadAge:
        state.age = 30
        return .none
    
    case .decreaseAge:
        if let age = state.age {
            state.age = age - 1
        }
        return .none
        
    case .increaseAge:
        if let age = state.age {
            state.age = age + 1
        }
        return .none
    }
}

struct HealthView: View {
    
    let store: Store<HealthState, HealthAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                Print("HealthView.render")
                Text("Your age is \(viewStore.state.ageString)")
                HStack {
                    Button(action: {
                        viewStore.send(.decreaseAge)
                    }, label: {
                        Text("-")
                    })
                    Button(action: {
                        viewStore.send(.increaseAge)
                    }, label: {
                        Text("+")
                    })
                }
            }.onAppear{
                print("HealthView.onAppear")
                viewStore.send(.loadAge)
            }.onDisappear(){
                print("HealthView.onDisappear")
            }
        }
        
    }
}



// MARK: - Utility

extension View {
    func Print(_ vars: Any...) -> some View {
        for v in vars { print(v) }
        return EmptyView()
    }
}



// MARK: - Settings View

struct SettingsState: Equatable {
    var nickName: String
}

enum SettingsAction: Equatable {
    case newNickName
}

struct SettingsEnvironment {}

var settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment>.init() { state, action, env in
    switch action {
    case .newNickName:
        state.nickName =  ["sazzy", "mickey"]
            .filter { $0 != state.nickName }
            .randomElement() ?? "kim"
        return .none
    }
}

struct SettingsView: View {
    
    let store: Store<SettingsState, SettingsAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                Print("SettingsView.render")
                Text("Hello \(viewStore.state.nickName)")
                Button(action: {
                    viewStore.send(.newNickName)
                }, label: {
                    Text("New identity")
                })
            }
            .onAppear{
                print("SettingsView.onAppear")
            }.onDisappear(){
                print("SettingsView.onDisappear")
            }
        }
        
    }
}



// MARK: - MainView

enum Tabs: Equatable, Hashable {
    case health
    case settings
}

struct MainState: Equatable {
    var health: HealthState
    var settings: SettingsState
    var currentTab: Tabs = .health
}

enum MainAction: Equatable {
    case health(HealthAction)
    case settings(SettingsAction)
    case selectedTab(Tabs)
}

struct MainEnvironment {}

var _mainReducer = Reducer<MainState, MainAction, MainEnvironment>.init() { state, action, env in
    switch action {
    case .selectedTab(let tab):
        print("--- Switching to tab '\(tab)'")
        state.currentTab = tab
        return .none
        
    case .health(let action):
        print("--- Handling action '\(action)'")
        return .none
        
    case .settings(let action):
        print("--- Handling action '\(action)'")
        return .none
    }
}

var mainReducer = Reducer.combine(
    _mainReducer,
    settingsReducer.pullback(state: \.settings, action: /MainAction.settings, environment: { env in
        return SettingsEnvironment()
    }),
    healthReducer.pullback(state: \.health, action: /MainAction.health, environment: { env in
        return HealthEnvironment()
    })
)

struct MainView: View {
    
    let store: Store<MainState, MainAction>
    
    var body: some View {
        WithViewStore(self.store) { viewStore in
            TabView(selection: viewStore.binding(
                        get: { $0.currentTab },
                        send: MainAction.selectedTab)
            ) {
                HealthView(
                    store: store.scope(
                        state: { $0.health },
                        action: { MainAction.health($0) }
                    )
                )
                .tabItem {
                    Image(systemName: "heart")
                    Text("Health")
                }
                .tag(Tabs.health)
                
                SettingsView(
                    store: store.scope(
                        state: { $0.settings },
                        action: { MainAction.settings($0) }
                    )
                )
                .tabItem {
                    Image(systemName: "gear")
                    Text("Settings")
                }
                .tag(Tabs.settings)
            }
        }
    }
}

Reproduction steps

Given the following steps:

  1. Run the project
  2. Switch to "Settings" tab
  3. Tap "New Identity"

The console output I would like to see:

HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render

Actual console output:

HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render
HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
SettingsView.render
HealthView.render

Solution

By scoping down the viewStore the output in the console looks very different

struct MainView: View {
    
    let store: Store<MainState, MainAction>
    
    var body: some View {
        WithViewStore(self.store.scope(state: { $0.currentTab })) { viewStore in
            TabView(selection: viewStore.binding(
                        get: { $0 },
                        send: MainAction.selectedTab)
            ) {
                HealthView(
                    store: store.scope(
                        state: { $0.health },
                        action: { MainAction.health($0) }
                    )
                )
                .tabItem {
                    Image(systemName: "heart")
                    Text("Health")
                }
                .tag(Tabs.health)
                
                SettingsView(
                    store: store.scope(
                        state: { $0.settings },
                        action: { MainAction.settings($0) }
                    )
                )
                .tabItem {
                    Image(systemName: "gear")
                    Text("Settings")
                }
                .tag(Tabs.settings)
            }
        }
    }
}

Expected console output:

HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render

Actual console output:

HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
SettingsView.render
HealthView.render
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render

This works fine. The only thing left now is to avoid that last excessive HealthView.render. It is possible to avoid that by conditionally rendering the views.

struct MainView: View {
    
    let store: Store<MainState, MainAction>
    
    var body: some View {
        WithViewStore(self.store.scope(state: { $0.currentTab })) { viewStore in
            TabView(selection: viewStore.binding(
                        get: { $0 },
                        send: MainAction.selectedTab)
            ) {
                Group {
                    if viewStore.state == .health {
                        HealthView(
                            store: store.scope(
                                state: { $0.health },
                                action: { MainAction.health($0) }
                            )
                        )
                    } else {
                        Text("Empty")
                    }
                }
                .tabItem {
                    Image(systemName: "heart")
                    Text("HealthView")
                }
                .tag(Tabs.health)
                
                Group {
                    if viewStore.state == .settings {
                        SettingsView(
                            store: store.scope(
                                state: { $0.settings },
                                action: { MainAction.settings($0) }
                            )
                        )
                    } else {
                        Text("Empty")
                    }
                }
                .tabItem {
                    Image(systemName: "gear")
                    Text("SettingsView")
                }
                .tag(Tabs.settings)
            }
        }
    }
}

Which then yields the following console output

HealthView.render
HealthView.onAppear
--- Handling action 'loadAge'
HealthView.render
--- Switching to tab 'settings'
SettingsView.render
SettingsView.onAppear
HealthView.onDisappear
--- Handling action 'newNickName'
SettingsView.render

:tada: Hurray. We've successfully removed all excessive work and potential unintented behavior.

Closing thoughts

Maybe Stephen or Brandon have some thoughts on this?
Do we like the if-clause?
Should it be necessary to scope down?
Is it okay to rely on .onAppear {} or should stuff like that (initial loading etc) be handled all the way by the reducer?

I think scoping down absolutely makes sense and that TCA is behaving as expected.
I don't like the if-clause and would love to know if there is a better way.

1 Like

The first thing I ask when it comes to behavior like this is if it's a TCA-specific problem or if it affects vanilla SwiftUI. TCA provides tools to solve problems like this (like store scoping), but wouldn't a vanilla SwiftUI app with an observable/state object have the same excessive calls to the body for tab views that aren't showing so long as those tabs observe state?

I think it makes sense! You're letting the hierarchy know not to compute the view if you're not looking at it.

Scoping is the tool TCA gives you to control these view computations :slight_smile: SwiftUI gives you bindings, which TCA stores can also produce.

Absolutely! onAppear and other lifecycle methods are perfect places to feed actions to the store.

Awesome! Thanks for the reply.

The first thing I ask when it comes to behavior like this is if it's a TCA-specific problem or if it affects vanilla SwiftUI. TCA provides tools to solve problems like this (like store scoping), but wouldn't a vanilla SwiftUI app with an observable/state object have the same excessive calls to the body for tab views that aren't showing so long as those tabs observe state?

Maybe? We use pullback extensively in our app. That means everytime there's a change in state way down in the state tree the whole state for the whole app changes. I don't think that's the case for most vanilla SwiftUI apps. So I would say this is indeed a SwiftUI problem, but the behavior is surfaced to due TCA having all state in a big state tree.

Each time you use a ViewStore you don't refresh the view if it's state is equal to the previous one whether the global state which contains your view state is changed or not.

Hi!

Long answer incoming, I'm sorry :pray:

I'm currently in the same situation, where I have a TabView encapsulated in a ViewStore so that I'm able to change the selectedTab programmaticaly (when receiving a deeplink, for example)

// MARK: - App

enum TabItem {
	case tab1, tab2, tab3
}

struct AppState: Equatable {
	var selectedTab: TabItem = .tab1
	
	var tab1State: TabContentState = .init()
	var tab2State: TabContentState = .init()
	var tab3State: TabContentState = .init()
}

struct AppEnvironment {}

enum AppAction {
	case selectTab(TabItem)
	
	case tab1(TabContentAction)
	case tab2(TabContentAction)
	case tab3(TabContentAction)
}

let appReducer: Reducer<AppState, AppAction, AppEnvironment> = .combine(
	Reducer { state, action, _ in
		switch action {
		case .selectTab(let tab):
			state.selectedTab = tab
			return .none
			
		case .tab1, .tab2, .tab3:
			return .none
		}
	},
    tabContentReducer.pullback(
        state: \.tab1State,
        action: /AppAction.tab1,
        environment: { _ in .init() }),
    tabContentReducer.pullback(
        state: \.tab2State,
        action: /AppAction.tab2,
        environment: { _ in .init() }),
    tabContentReducer.pullback(
        state: \.tab3State,
        action: /AppAction.tab3,
        environment: { _ in .init() })
)

struct MainView: View {
    let store: Store<AppState, AppAction>

    var body: some View {
        WithViewStore(store.scope(state: \.selectedTab)) { viewStore in
            TabView(
                selection: viewStore.binding(send: AppAction.selectTab)
            ) {
                TabContentView(store: store.scope(
                    state: \.tab1State,
                    action: AppAction.tab1),
                           tab: .tab1)
                .tabItem { Text("Tab1") }
                .tag(TabItem.tab1)

                TabContentView(store: store.scope(
                    state: \.tab2State,
                    action: AppAction.tab2),
                           tab: .tab2)
                .tabItem { Text("Tab2") }
                .tag(TabItem.tab2)

                TabContentView(store: store.scope(
                    state: \.tab3State,
                    action: AppAction.tab3),
                           tab: .tab3)
                .tabItem { Text("Tab3") }
                .tag(TabItem.tab3)
            }
        }
    }
}

// MARK: - TabContent

struct TabContentState: Equatable {
	var count = 0
}

struct TabContentEnvironment {}

enum TabContentAction {
    case increment
    case decrement
}

let tabContentReducer: Reducer<TabContentState, TabContentAction, TabContentEnvironment> = Reducer { state, action, _ in
	switch action {
	case .increment:
		state.count += 1
		return .none
		
	case .decrement:
		state.count -= 1
		return .none
	}
}

struct TabContentView: View {
    let store: Store<TCATabViewFeatureState, TCATabViewFeatureAction>

    init(
        store: Store<TCATabViewFeatureState, TCATabViewFeatureAction>
    ) {
        self.store = store
        print("TabContentView.init")
    }

    var body: some View {
        print("TabContentView.body")

        return WithViewStore(store) { viewStore in
            VStack {
                Text("Hello, World!")

                HStack(spacing: 10) {
                    Button("-") { viewStore.send(.decrement) }
                    Text("\(viewStore.count)")
                    Button("+") { viewStore.send(.increment) }
                }
                .padding()
            }
        }
    }
}

After we have visited each tab once, any time we select another tab, the MainView's ViewStore rerenders all of its tabs (call to .body) which can lead to obvious performance issues

The solution proposed by @hfossli works, but it feels kind of hacky and not very self-explanatory on why we do this in the code if we're not aware of the possible issue. And it adds some boilerplate as well. Although I think that in terms of performances only it looks the best.

I've tried different solutions, and I came up with one that almost mimic the SwiftUI's @State version of tab selection

The idea is to take advantage of the ViewStore's removeDuplicates closure to render its content only when we explicitly want to (in my case, it's when the app set a new selectedTab programmaticaly)

In the state, we would have the selectedTab value, but also another one that will give us the context of the selection; wether the user selected another tab, or it was done programmaticaly

enum TabItem {
	enum SelectionSource {
		case userTap, programmaticaly
	}
	
	case tab1, tab2, tab3
}

struct AppState: Equatable {
	var selectedTab: TabItem = .tab1
	var tabSelectionSource: TabItem.SelectionSource = .userTap
	
	var tab1State: TabContentState = .init()
	var tab2State: TabContentState = .init()
	var tab3State: TabContentState = .init()
}

enum AppAction {
	case selectTab(TabItem, source: TabItem.SelectionSource)
	
	case tab1(TabContentAction)
	case tab2(TabContentAction)
	case tab3(TabContentAction)
}

let appReducer: Reducer<AppState, AppAction, AppEnvironment> = .combine(
	Reducer { state, action, _ in
		switch action {
		case .selectTab(let tab, let source):
			state.selectedTab = tab
			state.selectionSource = source
			return .none
			
		case .tab1, .tab2, .tab3:
			return .none
		}
	},
...
)

And we can use the newly created selectionSource from the AppState in the MainView to render only when intended

struct MainView: View {
	 struct State {
	 	let selectedTab: TabItem
	 	let source: TabItem.SelectionSource
	 }
	 
    let store: Store<AppState, AppAction>

    var body: some View {
        WithViewStore(
            store.scope(state: Self.State.init),
            removeDuplicates: { previousState, newState in
                previousState == newState ||
                newState.source != .programmaticaly
            }
        ) { viewStore in
            TabView(
                selection: viewStore.binding(
                		get: \.selectedTab,
                		send: { .selectTab($0, source: .userTap })
            ) {
            		... tab views ...
            }
        }
    }
}

extension MainView.State {
	init(appState: AppState) {
		selectedTab = appState.selectedTab
		source = appState.selectionSource
	}
}

With this implementation, the view isn't rendered each time the user taps a tab (like vanilla SwiftUI with an @State value)

But I still get all tabs' views being rendered when I programmaticaly set the selected tab. To remove the undisplayed tabs rendering, I would still have to implement @hfossli's solution, and only then we would have, I think, a perfect mimic of vanilla SwiftUI's behaviour

What do you think of this approach guys ?

PS: One last desperate thought I had was to simply not use the TCA architecture for this view only, but I'm not sure that it can be done, to put a vanilla SwiftUI view in the middle of the TCA architecture views, because I'll ovisouly have this kind of view flow:

Launchscreen -> Gatekeeper (for login) -> MainView (the tabView) -> tabs

And they would all use TCA, but not the MainView