How do you best pass scoped stores thru multiple view layers?

How do you best pass scoped stores thru multiple layers?

I often see myself wanting to pass a scoped store to other views from the body, so that I can keep drilling it down even further in those views and only consume subsets of the state.

But I started to realize that it seems to be pretty much an anti-pattern to inject Store into Views and hold on to them in a property. (Beyond what WithViewStore does for example: turn it into a ViewStore in the initializer right away and only hold on to the view store.)
That is at least as long as not all of these are given:

  • the view is declared as Equatable
  • the store property is explicitly excluded from the equality implementation
  • and the view is used with the .equatable() modifier

Why? Because that would forgo all optimizations around drilling down to a minimal view state and removing duplicates, as it forces SwiftUI to re-evaluate the body whenever the store reference changes. SwiftUI's diffing relies internally on reference identity for reference-typed properties on views (when they have no property wrapper).

This might be fine as long as you're sharing your "main store" / single source of truth all around, which will be always the same reference (sounds like overall a bad idea tho, bc it forgos the C in TCA), but becomes problematic at the latest when you're scoping stores. When scoping stores, you always get a fresh new store instance whenever Store.scope is called. So when it's called from a View.body and injected into other Views, then that would effectively mean that whenever the parent in such a scenario gets its body re-evaluated, the whole affected subview hierarchy will have to be re-evaluated as well.
(Note: It seems that this is not usually the case with "vanilla" SwiftUI. Parent bodies can be re-evaluated independently. The children will be only re-evaluated, when SwiftUI decides per its diffing that the children at hand are actually different.)

So that also means scoping from the View.body or a view's computed variables would be pretty much an anti-pattern as well – if and only if you're planning to use this scope beyond turning it into a ViewStore right away. (e.g. via WithViewStore)

So where do I scope now? If I scope my store in the initializer, then I need to hold on to the scoped store somewhere – a stored property? Well, now I've run into the very same problem again.
Sooo does this eventually mean, I should only really inject scoped stores into equatable views which ignore the store? At least if I care about the diffing performance and want to keep the number of body calls down. I'm aware that Swift structs are relatively cheap. I'm not quite sure how much overhead TCA adds to that with its internal combine operations and Store/ViewStore allocations.

For my project I'm observing an unreasonable number of updates and wish to cut down on that, to improve performance on the one hand, but also just alone to improve debugging experience on the other hand.

5 Likes

You'd be passing that function to your reusable view as a prop. mount) and supplies a part of itself, or a property configured in the top layer, You define your service object outside the application, or at least, higher than the redux store. I needed some formatting logic to be shared across multiple components .

Did you have a fix on this issue? I have the same problem with Facing same issue but no response from anyone and couldnt find this topic troubleshooting in google.

To be honest, I'm not quite sure what you were trying to say in your previous answer.

I've been able to manage my performance issues by proactively removing hidden parts of view hierarchy and relying on EquatableView for frequently re-rendered and expensive parts of the view hierarchy. This does come at a cost and adds a bit of lag to transitions, when those hidden parts are shown again, but so far I'm better off with taking this.

Previously I've even tried to cache my store scopes, by a rather dirty cobbled together extension on Store, relying on associated objects. But that has led to the app completely blocking the main thread by repeated actions, which might be a problem somewhere with my app, but I couldn't find the exact culprit.

2 Likes

Thanks for sharing your solution regarding the .equatable() modifier - I was not aware any such thing existed. I have had the same issue with a view dependent on state derived from multiple other states (as per the official best practice), where state controlling drag offset for one view would cause multiple other, unrelated views in the same "branch" to rerender themselves as they were dependent on the same base state higher up the hierarchy.
The ViewStore's removeDuplicates does not appear to protect against any such unnecessary rerenders and I was not able to figure out why. For instance, one view's state was dependent only on a single integer value that was not mutated by the dragging, but kept rerendering with every state update up the hierarchy anyways.
Would be very interested to learn if I there is a fundamental misconception in the way I am using the Composable Architecture or if this is something that can and should be adequately handled by using Equatable views.

I can’t say I’ve seen the behaviour you describe. Why would SwiftUI re-render a view if the store reference changes? Stores are not observable objects so should have no impact on rendering. Passing them into views and storing them in a property is exactly how they should be used. I suspect you may have an issue elsewhere in your state causing unnecessary renders.

Thanks for your reply! Of course, it may well be that I am mistaken here, but to my eyes it is not the store reference that changes but the state itself, which causes a notification to be sent to SwiftUI via the viewStore's objectWillChange. This becomes relevant because, if you derive state from multiple other states, I understand that you are supposed to construct it as a computed property with a getter and setter.

Now, let's say some state X is derived from base states A, B and C, and constructed on the app state level as a computed property, then "passed down" via scoping into one branch of the view hierarchy. Suppose on that level View1 is dependent on properties from A and B and View2 is only dependent on properties from C (oversimplified). If in View1 some property from A changes, it is propagated up to the app state, where the state is set anew. This causes, at least for me, rerenders of View2 as well, despite explicitly declaring removeDuplicates: == (it's a tuple state) in the ViewStore for View2 and checking that the state does, in fact, not change. However, views outside of this "branch" of the view hierarchy are unaffected and don't rerender, so the removeDuplicates does show its intended effect there.

This problem appears to be solely connected to deriving the state from other state, which is something that, again to my understanding, cannot be avoided if you work with complex state that extends to multiple areas of your app. I would be glad if you could point me to any misconceptions I might have (because it definitely feels like I'm doing something not intended by the Composable Architecture), but for me the only way to stop those rerenders was to make the views themselves equatable, as outlined above by mrackwitz.

If you’re seeing this I would suggest trying to put together an example app and opening an issue on GitHub.

If the parent state changes but none of the properties that are used in the derived state change, then the derived state will be unchanged and the view store scoped to that derived state should not publish any changes because of the removeDuplicates call internally.

How are you determining that your view is re-rendering? Have you tried explicitly subscribing to the ViewStore’s objectWillChange publisher and seeing when it emits?

Just re-reading your post - are you saying that View1 and View2 are both scoped to state X? If so then any change in state X will cause both views to re-render.

If View1 only uses values A and B and View2 only uses value C then it sounds like each view should have their own derived states rather than using just state X.

I think we do need to differentiate in this discussion (re-)rendering from body re-evaluations. As far as I know, these are two different steps when it comes to SwiftUI.

While I agree with the should part, this is factually not true. TCA's stores are to SwiftUI opaque reference-managed objects. SwiftUI has no concept that it should compare them only based on their state. (They do not implement Equatable. This in fact could be in theory misleading if two scoped stores rely on the same state information, but dispatch their actions differently. That's a fairly constructed case, but one to consider from a framework maintainers perspective, I suppose.)
If a view's body is re-evaluated higher up, and so a scope is re-invoked, this will produce a new store reference, thus the view will appear to have changed in terms of its properties to SwiftUI and it will re-evaluate the body in any case.

Can you post an example project demonstrating this because I still don’t see how this could be causing an issue. If you rewatch the Adaptive State videos on Pointfree you can see that introducing the ViewStore and scoping your view state properly specifically solves this problem.

Do you have any references that document this behaviour of SwiftUI because my understanding was that the view diffing mechanism was driven by the type system and has nothing to do with its non observable properties.

That is exactly what I thought, but for my code at least that is not what's happening. I'm going to put up an example app on GitHub shortly to demonstrate.

I used a debug print statement within the body. It gets evaluated when the body property is computed so that should be quite accurate. I could not think of a better way to test this, so please excuse the caveman style.

No, the views are scoped to their individual states, which are both derived from state X, but importantly do not share the same parts of that state.

Could you further explain what you mean by that? I was under the impression that SwiftUI only reevaluates the body property when it is notified of state changes that the view depends on, causing a rerender (see, for instance: https://link.medium.com/YQuPiv5EGdb, the part that is titled "State Change").

Notably, if you insert a print statement both in the views initializer and within the body property, the initializer's print gets printed a ton whereas the body print outputs only once, indicating that the view is initialized frequently but the body property only reevaluated once (causing a rerender). This happens after the addition of the .equatable modifier to the view. Before that, both the body and the initializer had the same print output, causing the dips in performance.

Once again, this pertains only to views on the same branch of the view hierarchy. The other branches are unaffected by the change in state for that branch, as they should be using the removeDuplicates operator. Looking at the internal mechanisms of the ViewStore, it's quite baffling, as from a logical perspective this should not happen.

Ok, I think I have a hunch about what the problem is here.

Does your parent view use either an ObservedObject view store property or are you initialising the child views (that have the scoped stores) inside a WithViewStore scoped to the parent state?

If so, this is going to cause your parent view body (or the contents of the WithViewStore) to be recomputed whenever the parent view state changes, including the child views even if their child state is unchanged. This is expected behaviour and is an indicator that you have a scoping problem.

If you are creating child views with a store scoped to some specific sub-state, and that child view observes the child state using WithViewStore on the scoped store it will recompute its body if and only if the child state changes (other changes to the parent store that don’t affect the child state will be discarded because of the removeDuplicates call). These views do not need to be inside a WithViewStore on the parent and doing so causes the problem you’re observing.

So the solution is to move the child views outside of any parent WithViewStore.

The parent view does use a WithViewStore (actually I just implemented it - before it ran on an @ObservedObject viewStore because of a GeometryReader. Weirdly, it still works as intended despite wrapping the GeometryReader). Do I understand you correctly that you are suggesting to use a WithViewStore scoped to the child view's state? If so, I tried that to no avail.
I am not sure what you mean by "move the child views outside of any parent WithViewStore". The parent view's WithViewStore is at the topmost scope, so there is no way of initializing the child views outside of it. If I misunderstood you, I'd be glad for some clarification.

On a completely unrelated note, using the WithViewStore within the GeometryReader as one is supposed to according to the official release notes, completely messes up my interface, whereas wrapping the GeometryReader in the WithViewStore does not block state updates from travelling down the hierarchy (again as per the official release notes).

Also, removing or adding the @ObservedObject property wrapper on the viewStore has no effect whatsoever (no WithViewStore used).

If you can throw up an example project I'd be happy to look at it but yes, I do literally mean moving the scoped child views out of the WithViewStore - putting them inside the WithViewStore scoped to the parent state will mean the contents of the WithViewStore view will get re-evaluated every time the parent state changes, even if the child states of the scoped views have not.

It was hard for me to provide code examples as I was replying from my iPad yesterday so let me try and demonstrate.

Assuming you currently have something like this:

struct ParentView: View {
  var store: Store<ParentState, ParentAction>

  var body: some View {
    WithViewStore(store) { vs in
      // other views here
      SomeChildView(store: self.store.scope(state: \.childState, action: /ParentAction.child)
    }
  }
}

You could change the body to this:

  var body: some View {
    Group {
      WithViewStore(store) { vs in
        // other views here
      }
      SomeChildView(store: self.store.scope(state: \.childState, action: /ParentAction.child)
    }
  }

This will prevent SomeChildView from being re-initialised when the parent state (but not anything that is used to compute childState) changes. SomeChildView's body will still get re-evaluated when childState changes.

Alternatively, if you're not actually accessing any of the parent state inside the WithViewStore but are using it to send actions, you could scope that down to a stateless store:

  var body: some View {
    WithViewStore(store.stateless) { vs in
      // can send ParentActions using vs but state is void and this content will never change
      SomeChildView(store: self.store.scope(state: \.childState, action: /ParentAction.child)
    }
  }

If you don't need to read the parent state or send parent actions, then you don't even need the WithViewStore in the parent at all!

Thank you for taking the time to explain your reasoning. So I did understand you correctly. However, that kind of scoping is not feasible in my particular scenario. My view tree looks like this:

var body: some View {
    WithViewStore(store) { viewStore in 
        ZStack {
            ...
            ChildView(store: store.scope(...))
        }
       .offset(x: viewStore.someState)
    }
}

The fact that the ZStack needs to access the viewStore's state makes it impossible to remove the child view from the scope. Or am I missing something very obvious here?
Also, how would you go about solving the rerendering issue, should your view be driven off of an @ObservableObject viewStore (due to a GeometryReader necessitating such a usage)?

Yes, that’s a tricky case. No obvious solution is jumping out at me for your scenario unfortunately. I don’t think this is a problem unique to TCA though and you can run into these issues using things like @State and @ObservedObject and view models.

Could you possibly scope down the parent state to just the state you need for that ZStack? That should at least get rid of some body reevaluations.

It’s also probably quite valid to make use of EquatableView for scenarios like this. This feels like an edge case rather than a normal case though.

There’s no easy solution if you need to use @ObservedObject, but it really depends how you’re using GeometryReader in relation to the scoped child views. If possible you could try and move the part of the view that needs to use a GeometryReader into its own child view and move the @ObservedObject into that child view, but there will be cases where you can’t do this.

Terms of Service

Privacy Policy

Cookie Policy