Skip to content

Conversation

stephencelis
Copy link
Member

The existing WithViewStore initializers make it far too easy to introduce performance problems into an application as it grows, and not easy to understand what's going wrong without more intimate knowledge of the framework and its documentation.

Because of this, we'd like to deprecate the existing initializers and introduce new initializers that make view state observation very explicit:

// Before:

WithViewStore(self.store) { viewStore in
  // ...
}

// After:

WithViewStore(self.store, observe: { $0 }) { viewStore in
  // ...
}

It is now very explicit what state a view is observing, and we think it will stand out more when users encounter performance issues. It is something that is easier to teach and learn.

Instead of scoping stores and then handing them to WithViewStore, you can pass state and action transformations directly, which even saves a few keystrokes:

// Before:

WithViewStore(self.store.scope(state: ViewState.init, action: Action.init)) { viewStore in
  // ...
}

// After:

WithViewStore(self.store, observe: ViewState.init, send: Action.init) { viewStore in
  // ...
}

So in short, instead of reusing Store.scope for two different kinds of transformations, we have separated them out to be more specific. The general rules:

  • Use Store.scope to pass a child store to a child view
  • Use WithViewStore(_:observe:) from a child view to observe view state

The deprecations in this PR are soft and should not cause warnings or churn in your applications, but you will start to be able to use the new APIs as soon as they are released. Then in the future we will deprecate the old APIs with a little more fanfare.

Please let us know if you have any feedback!

@stephencelis stephencelis merged commit bef2084 into main Sep 7, 2022
@stephencelis stephencelis deleted the with-view-store-observe branch September 7, 2022 14:08
@square-tomb
Copy link

Looks nice! I'm curious, what sort of performance problem is easier to introduce with the older initializers?

@mbrandonw
Copy link
Member

mbrandonw commented Sep 8, 2022

@square-tomb We have some information written up about this in our performance article.

In short, doing WithViewStore(self.store) means observing all state, even if your view only needs a small bit of that state. That's particularly bad towards the root of your application. We want people to be more mindful when observing state. To do that, you just need to supply an observe closure that plucks out the bits of state your view actually needs to do its job.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

I guess I'm too late on this, but I have a hard time seeing the logic here. Surely the right way to use TCA is to scope your store before sending it to a child view? That means that when you do the right thing, you are punished with having to write a lot of observe: { $0 }. I mean would it be bad architecture to send to parent store into the child view? That seems to go against the idea of modularity, or what am I missing?

// MARK: Content

struct Content: ReducerProtocol {
    struct State: Equatable {
        var profile: Profile.State
    }

    enum Action: Equatable {
        case profile(Profile.Action)
    }

    func reduce(into state: inout State, action: Action) -> Effect<Action, Never> {
        // ...
        return .none
    }
}

struct ContentView: View {
    let store: StoreOf<Content>

    var body: some View {
        ProfileView(store: store.scope(state: \.profile, action: Content.Action.profile))
    }
}

// MARK: Profile

enum Profile {
    typealias Store = ComposableArchitecture.Store<State, Action>

    struct State: Equatable {
        var name: String
    }

    enum Action: Equatable {
        case changeName(String)
    }
}

struct ProfileView: View {
    let store: Profile.Store

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            Button {
                viewStore.send(.changeName("..."))
            } label: {
                Text(viewStore.name)
            }
        }
    }
}

@mbrandonw
Copy link
Member

@GreatApe Your ContentView currently doesn't need any state in the body, but what if it did? What would you do?

If you haven't already, read the view store section of the performance article. It should answer your question.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

Sure, I understand that stores should be scoped before you make a viewstore for them, that's what I do in my example. And of course there are situations where you may want to scope down the store within the view itself, for example when you need to both pass down a scoped store to a child view and use another scope in your own body.*

But there are also plenty cases where you don't need to do that, cases where you are following all the rules and being 100% efficient. So it's hard to understand that you would have to add observe in those cases.

* Though these can always be handled by introducing another child view, if you really want to avoid observe:

struct ContentView: View {
    let store: StoreOf<Content>

    var body: some View {
        ProfileView(store: store.scope(state: \.profile, action: Content.Action.profile))
        BodyView(store: store.scope(state: \.body, action: body))
    }
}

@tgrapperon
Copy link
Contributor

As an aside both scope operations (to form the child domain, or to form the ViewState) are fundamentally different. The later is a projection of the child store's state into the real world. It happen to be a fully fledged scope operation only because it's convenient to do so, but this is not a fundamental requirement of ViewStore that could proceed differently internally. I'm convinced that this uncoupled approach will bring more than performance in the long run.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

What is the difference? Except that in the observe case you only scope the state.

@mbrandonw
Copy link
Member

* Though these can always be handled by introducing another child view, if you really want to avoid observe:

This isn't true, it cannot always be handled like that. As the performance article shows, if the root view of a tab-based application needs access to the selectedTab state then naively one might do:

WithViewStore(self.store, observe: { $0 }) { viewStore in
  TabView(selection: viewStore.binding(state: \.selectedTab, send: AppAction.tabSelected)) {
    
  }
}

However, this is not optimal at all. It is observing all of app state just to pluck out a single field.

Instead what should be done is the surrounding WithViewStore should single out the state it needs to do its job, which in this case is just the selectedTab field:

WithViewStore(self.store, observe: \.selectedTab) { viewStore in
  TabView(selection: viewStore.binding(send: AppAction.tabSelected)) {
    
  }
}

In general, the state held in a Store represents a lot more than what the view needs. This is most true the closer you are to the root of the application and least true the closer you are to a leaf view. Even if you are being meticulous with how you scope stores to pass to child views, if you are still performing WithViewStore with an un-scoped store you are definitely observing too much state.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

So I guess there are cases where you can't avoid it. But again, I completely understand that stores should be scoped before making view stores, and that there are cases where you should use observe. That is not my point at all.

I'm just saying that since for child views you will typically do the scoping before passing it to the child, there are many situations where you don't need to do any further scoping. Two very common cases, off the top of my head:

  • In leaf views with a single ViewStore
  • In views where only child views need state

So my point is that since there are plenty of situations where legitimately don't need to observe, it should not be a mandatory parameter.

@mbrandonw
Copy link
Member

@GreatApe Can you quantify just how many features you think do need to further scope view state versus those that do not? Are there more leaf features than root/intermediate features? Is it a 50/50 split?

The fact is this is a problem 100% of people will run into if they don't know to properly scope view state. It is by far the most common problem we help people with in all the various outlets (forums, discussions, twitter, slack), and even in just the month since this PR was opened we have responded to this exact problem on 3 occasions.

You might be interested in the RFC discussion for this change here #1340. We discuss at length how pervasive this problem is and why we think some visibility in the API is a step in the right direction.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

I was just looking at my own app, it has 50-60 views. It's only really close to the root that I use observe, for example around the TabView. Maybe 4-5 cases. The way I do it then is to have a ViewState on the View and observe to that:

extension Profile.State {
    var viewState: ProfileView.ViewState { fatalError() }
}

struct ProfileView: View {
    struct ViewState: Equatable {
        let name: String
    }

    let store: Store<Profile.State, Profile.Action>

    var body: some View {
        WithViewStore(store, observe: \.viewState) { viewStore in
            Text(viewStore.name)
            ChildView(store: store.scope(state: \.child, action: Profile.Action.child))
        }
    }
}

Close to the leafs I often jump to vanilla SwiftUI:

struct JustAboveLeafView: View {
    let store: Store<AboveLeaf.State, AboveLeaf.Action>

    var body: some View {
        WithViewStore(store) { viewStore in
            LeafView1(name: viewStore.name)
            LeafView2(age: viewStore.age)
            LeafView3(sex: viewStore.sex)
        }
    }
}

Of course this means that the body of JustAboveLeafView is called more than strictly necessary, but the almost-leaf views tend to be pretty simple anyway, and it's worth it to keep things simple, instead of say using three separate viewstores or something.

Again, I'm definitely not arguing against the existence of observe, I just don't think it should be mandatory for everyone everywhere, just to nudge a handful of beginners towards a more efficient architecture. That can and should be done in other ways. You emphasise scoping in almost every single video, so I feel that if someone still has somehow missed that, I feel that they will have to accept the consequences. It's a general SwiftUI concept really.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

I'm curious, in this example, if we had stuff in the TabViews that used state directly, would it be more efficient with several viewstores, or just to observe something that has both the selectedTab and the inner state?

WithViewStore(self.store, observe: \.viewState) { viewStore in
  TabView(selection: viewStore.binding(send: AppAction.tabSelected)) {
    Text(viewStore.string)
    Rectangle
      .fill(color)
    ChildView(store: store.scope(...))
    
  }
}

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

@tgrapperon
What is the difference between scoping and observing? I thought observing was just state-only-scoping?

@rcarver
Copy link
Contributor

rcarver commented Oct 7, 2022

I completely understand that stores should be scoped before making view stores, and that there are cases where you should use observe

Let's break this down a bit more, as I don't think the statement "stores should be scoped before making view stores" is quite accurate. Instead:

  1. A store is scoped (with state, action) to take the shape of another domain. This is often necessary to pass the store to a View that expects that domain. It has nothing to do with performance.
  2. A ViewStore should observe the minimum state needed to drive its content.

As @tgrapperon said here these both uses the scope operator for convenience but they're quite different in use and pulling them apart is what this entire observe change about.

/// (1) A view in the A domain. It doesn't use any state, just passes the store along
/// to views of domain A and B
struct ContentA: View {
  let store: Store<StateA, ActionA>
  var body: some View {
    // Another view in the A domain. We don't care what state it actually uses.
    NestedContentA(store: store)
    // A view the A domain. We alter it shape to B, but again, don't care what state it uses.
    ContentB(store: store.scope(state: \.b, action: ActionA.b)
  }
}

/// (2) Observing state using `WithViewStore`
struct NestedContentA: View {
  let store: Store<StateA, ActionA>
  var body: some View {
    // Observing just the `name` field to render this view, then passing the whole store to
    // another nested view of the A domain
    WithViewStore(store, observe: \.name) { viewStore in 
      Text(viewStore.state)
      MyLeafView(name: viewStore.state)
      MoreNestedContentA(store: store)
    }
  }
}

In leaf views with a single ViewStore

Yes this is true. But, when I see an un-scoped ViewStore it's an indication that something may be less efficient than it could be

In views where only child views need state

What do you mean by this?

@mbrandonw
Copy link
Member

Again, I'm definitely arguing against the existence of observe, I just don't think it should be mandatory for everyone everywhere, just to nudge a handful of beginners towards a more efficient architecture. That can and should be done in other ways.

Not making it mandatory would be equivalent to the status quo before the change. No one would use observe because it isn't in your face letting you know that specifying what you observe is an important part of creating view stores.

You emphasise scoping in almost every single video, so I feel that if someone still has somehow missed that, I feel that they will have to accept the consequences. It's a general SwiftUI concept really.

The library is meant for people beyond the Point-Free community who may have never watched a video. And so far history has shown that ViewStore.init and WithViewStore.init is a pretty serious foot gun, even for people who have watched the videos.

@mbrandonw
Copy link
Member

And to share more stats, isowords currently has 23 instances of creating a view store with a scope to view state, and 26 uses of constructing a view store un-scoped. So nearly 50/50. I wonder how other people's code bases fare.

@rcarver
Copy link
Contributor

rcarver commented Oct 7, 2022

Calls to WithViewStore in my app:

  • scoped: 72
  • scoped stateless: 33 (Action-sending only)
  • un-scoped: 59

@tgrapperon
Copy link
Contributor

tgrapperon commented Oct 7, 2022

In my WIP app, I'm currently at 132 ViewStores in total, with 90 of them having a ViewState (so around 70%). As I'm implementing domains backward, I'll probably reach around 80% at the end, as I'll work closer to the root.
I have a very demanding app in terms of UI performance, so I'm particularly attentive to only observe the minimum required. This probably explains the higher amount of ViewStates.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

Let's break this down a bit more, as I don't think the statement "stores should be scoped before making view stores" is quite accurate. Instead:

  1. A store is scoped (with state, action) to take the shape of another domain. This is often necessary to pass the store to a View that expects that domain. It has nothing to do with performance.
  2. A ViewStore should observe the minimum state needed to drive its content.

This is what I meant. I am not and have never disputed this. I feel that I repeat that in every post.

In views where only child views need state

What do you mean by this?

By state I mean information passed in from the outside, ultimately coming from the root. Views are either system views or custom views. Both types can either use outside state or be stateless. We can ignore the stateless ones here. So now most (all?) views have one of these forms, focusing on stateful views:

A. No stateful views in the body
B. Only system views - these are leaf views
C. Only custom views
D. A mix of system and custom views

Examples:

struct ViewA: View {
    var body: some View {
      Text("Hello world")
    }
}

struct ViewB: View {
    let store: Store<B.State, B.Action>

    var body: some View {
        WithViewStore(store) { viewStore in
            Text(viewStore.title)
            Text(viewStore.subtitle)
        }
    }
}

struct ViewC: View {
    let store: Store<C.State, C.Action>

    var body: some View {
        VStack { 
            Text("Children:")
            Child1(store.scope(state: \.child1, action: C.Action.child1))
            Child2(store.scope(state: \.child2, action: C.Action.child2))
        }
    }
}

struct ViewD: View {
    let store: Store<D.State, D.Action>

    var body: some View {
        WithViewStore(store, observe: \.viewStateD) { viewStore in
          VStack { 
              Text(viewStore.title)
              Text(viewStore.subtitle)
              Child1(store.scope(state: \.child1, action: C.Action.child1))
              Child2(store.scope(state: \.child2, action: C.Action.child2))
          }
       }
    }
}

It seems to me that D is the only case where we need to use observe, we can be maximally efficient without it. And D can often be converted to C, but apparently not always.

struct ViewDasC: View {
    let store: Store<D.State, D.Action>

    var body: some View {
        VStack { 
            Titles(store.actionless.scope(state: \.titles))
            Child1(store.scope(state: \.child1, action: C.Action.child1))
            Child2(store.scope(state: \.child2, action: C.Action.child2))
        }
    }
}

Perhaps the rule is that you can't live without observe in case where you need to use both store and viewStore in the same body?

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

Your stats seem to strongly support my argument. Surely it's not nice to have 30% of your usages of WithViewStore having that ugly { $0 }?

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

Tangentially related. Is there any difference in rendering here:

struct ViewB1: View {
    let store: Store<B.State, B.Action>

    var body: some View {
        WithViewStore(store) { viewStore in
            Text(viewStore.title)
            Text(viewStore.subtitle)
        }
    }
}

struct ViewB2: View {
    let store: Store<B.State, B.Action>

    var body: some View {
        WithViewStore(store, observe: \.title) { viewStore in
            Text(viewStore.state)
        }
        WithViewStore(store, observe: \.subtitle) { viewStore in
            Text(viewStore.state)
        }
    }
}

@mbrandonw
Copy link
Member

It seems to me that D is the only case where we need to use observe,

I do understand that you are not arguing against scoping for view stores, and it does not need further repeating for me. But I'm not sure you understand all the situations in which un-scoped WithViewStore is problematic and how pervasive it is.

In your examples, ViewB is also problematic unless it is a leaf view. And being a leaf view is not a permanent condition. In a few weeks ViewB may need to show SettingsView in a sheet and then ViewB will be re-computed every time something in settings changes. Or ViewB will start executing a complex effect that sends a bunch of actions back into the system to accumulate some internal state that only the reducer needs but the view doesn't, but now the view is re-computing on all of those internal changes.

Tangentially related. Is there any difference in rendering here:

Yes because it is not set in stone that B.State will only ever hold onto title and subtitle. In will almost always evolve into something that holds onto more state, and when that day comes it will observe more than it needs to.

Now I would never suggest making views like ViewB2. I don't think the complexity of observing each individual field for each view component is worth it. It's even possible that multiple observations turns out to be less efficient than one well-scoped WithViewStore for the entire view.

@tgrapperon
Copy link
Contributor

Your stats seem to strongly support my argument. Surely it's not nice to have 30% of your usages of WithViewStore having that ugly { $0 }?

It depends if you put the 50% of wrong uses by hundreds (thousands?) of inexperienced users in the balance. This is what is at the core of the PR. Experienced users will know how to define themselves a WithViewStore(store) { … } function in some View extension to shadow the deprecation and use WithViewStore as they see fit.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

In your examples, ViewB is also problematic unless it is a leaf view

B is a leaf view though.

And being a leaf view is not a permanent condition

Well, if it changes into a D then it's no longer a B. Then it's a different situation. I'm trying to understand what different cases there are. Obviously one situation can turn into another.

Yes because it is not set in stone that B.State will only ever hold onto title and subtitle. In will almost always evolve into
something that holds onto more state, and when that day comes it will observe more than it needs to.

I'm talking about the way B is now, not what it could become. Assume that the only state here is title and subtitle. In other words, does it help to give them their own view stores? It saves activating the view stores, but will it affect the rendering? In B1 the WithViewStore body will get called whenever either title or subtitle changes, right? Does that cause the Texts to render, or will they simply init and check if their respective text changed?

@mbrandonw
Copy link
Member

In other words, does it help to give them their own view stores? It saves activating the view stores, but will it affect the rendering? In B1 the WithViewStore body will get call whenever either title or subtitle changes, right? Does that cause the Texts to render, or will they simply init and check if their respective text changed?

SwiftUI is too much of a black box to know what Text is doing under the hood. The only material difference we can definitely see is that ViewB1 subscribes one time, and so its closure will be invoked when either field changes, and ViewB2 subscribes two times, and so each closure will be invoked when each field changes.


To back up a bit, I think we should re-evaluate whether this conversation is being productive, or if a closed PR is the proper place to have it. There are multiple lines of thought happening but no way to thread the discussions.

If we want to keep discussing this then I think a dedicated discussion should be started. But we will need to see something more substantial than a slight annoyance of having to specify observe: { $0 } in leaf views to sway us. We feel strongly, and have explained in depth multiple times, why we think over-observation is such a problem in SwiftUI and that we are looking for solutions to that problem.

So I think this conversation can be most fruitful if we are finding solutions to that problem and not trying to remove the only thing that publicly lets people know that they are observing state when constructing view stores. If we find a better solution than observe: { $0 } someday, then we would happily remove it.

@mbrandonw
Copy link
Member

And I should mention that @stephencelis and I have spent a substantial amount of time looking for alternatives to view stores in general (with extensive help from @tgrapperon) so that this whole conversation becomes moot. But sadly it has alluded us thus far.

We are not suggesting that observe: { $0 } is the most optimal solution to the problem, and we will continue researching ways to improve this, but until then we feel this a small step towards letting people know what is going on.

@GreatApe
Copy link

GreatApe commented Oct 7, 2022

I think the Observe solution that someone suggested is very good, it makes it very clear. I never found the ViewStore name very intuitive, especially since it apparently is more of a "StoreView", a view into a store. Although that name would also be confusing in a SwiftUI context.

The fact that new ViewStores are created all the time always makes me uneasy, being reference types. And an ObservedObject to boot. It feels like it can't be good, that SwiftUI will get tripped up from re-subscribing all the time, or something. But it seems to work without issue.

Without knowing anything about how it works, wouldn't one solution be to have a permanent, private property on Store that exposes some kind of observing entity, and then the WithObserver call would just expose that to the outside? Then we'd at least have the same instance all the time, it could even be a value type. But I should probably first try to understand what it does before coming with suggestions.

Just one more random idea, pretty different from today:

struct FeatureView: View {
  @Observe(\.title, \.subtitle) var store: StoreOf<Feature>

  var body: some View {
      VStack {
          Text(store.title)
          Text(store.subtitle)
      )
  }
}

Feature.State here would contain lots of other state, but we would only observe title and subtitle, they would trigger the redrawing. This is a less dynamic solution than WithViewStore but it seems to correspond to having ViewStore as a property at least. Maybe it can be expanded. If it can even be made to work.

Speaking of Stores, most of mine have no corresponding reducer, they are just used for scoping. I don't know if this is the intended usage, but it does cause some confusion since the two types of stores are logically pretty different, but they have the same name. Similarly I found it hard to wrap my head around scoping existing both in the Store and the Reducer domain, sort of in parallel.

Btw it's not just leaf views, it's 90% of my views, because I really made an effort to be efficient.

@mbrandonw
Copy link
Member

mbrandonw commented Oct 7, 2022

Without knowing anything about how it works, wouldn't one solution be to have a permanent, private property on Store that exposes some kind of observing entity, and then the WithObserver call would just expose that to the outside? Then we'd at least have the same instance all the time, it could even be a value type. But I should probably first try to understand what it does before coming with suggestions.

While sounding great theory, it would mean that Store now needs to be generic over two types of states (the feature state and the view state), and if you want to handle view actions then it would need 4 generics. So then maybe you need some kind of SimpleState<State, Action> typealias to hide that away, but then there are all types of ergonomics and API problems to think through. And even if that was all solvable, it would still allow a path for someone to blindly observe all of state without knowing the consequences.

I recommend going through the exercise of exploring that problem space so that you can see the tradeoffs yourself, and if you find something interesting we'd love to see it.

Just one more random idea, pretty different from today:

I suggest you try implementing this too to see what goes wrong.

Speaking of Stores, most of mine have no corresponding reducer, they are just used for scoping. I don't know if this is the intended usage...

I would say no, this is not the intended usage. Maybe another topic for a discussion.


I'd like to iterate that I think a new discussion is best for continuing these explorations, especially for considering things like Observe. A closed PR is not appropriate.

@fabstu
Copy link

fabstu commented Oct 7, 2022

Sorry for writing this here, so feel free to ignore it.

I have a possibly foolish idea to solve the problem: Can the Store/ViewStore record what values got accessed with the last rendering and emit a re-render only when these values change?

I feel like this is something SwiftUI should do, but maybe I am missing something. Maybe SwiftUI does not have the structure the values are based upon, and so cannot put itself between the structure and the value access to record that, but rather only gets the data and so is unable to do any such thing.

edit: Small poc posted here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants