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.

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.

Terms of Service

Privacy Policy

Cookie Policy