Non-Global View States in Swift Composable Architecture

I come from a React background, and Im having a difficult time wrapping my head around what is likely a basic concept in Swift Composable Architecture:

From all of the examples, e.g.; The Tic Tac Toe example, it seems that reducers are entirely combined within the global App State, and extracted for views by scoping the global Store. Fundamentally, Im confused as to how to avoid this and keep very specific view state, actions, and reducer within a View itself, while still having access to the global App State and store.

For example, if I want to keep track of the global "route" in App State, but the value for a text input within the view, how exactly am I supposed to do this? I know I can use WithViewStore to get the global store, but how do I keep access to the local reducer code?

What about adding a separate Store to the given view, that holds its state? I think you can make it decoupled from the main Store that drives the whole app. Instead of scoping the main store, you can just create a new, view-specific store. The downside of this approach would be that you won't have a single source of truth that holds a state of the whole app. It won't be possible to restore the whole app from a single state, for example. Perhaps it's not what you need.

Yeah this is what I've done so far but just not clear if this is a desirable approach, both from an architecture perspective and especially from a performance perspective. From what I understand, the in order to do the approach you described while still having access to the global state within my view, I'd need to double-wrap the view contents in WithViewStore calls. Is this the right way to handle this?

  var body: some View {
    WithViewStore(
      appStore.scope(state: \.routing, action: AppAction.routing)
    ) { routingViewStore in
      WithViewStore(
        localStore
      ) { localViewStore in
        // ... more
      }
    }
  }

WithViewStore requires that the state of the store you pass into it is Equatable. That’s because it will update the view, whenever the state changes (and equality is used here to check for it). Optionally you can also provide removeDuplicates parameter to be used instead of state’s equality.

In your case the outer ViewStore will trigger update only if \.routing is changed. The inner ViewStore will update the view whenever localStore is changed.

If this describes what are you trying to achieve - you are good to go.

Thanks for the help Dariusz. One more question regarding this if you don't mind, do you happen to know if there are any performance (or otherwise) concerns with doing either of the following:

  • I think I prefer the syntax of using ObservedObject over wrapping items in WithViewStore. Would this have negative impacts? Am I correct in understanding that the key point here would be that the entire view using ObservedObject would re-render on changes, whereas only sub-views wrapped in WithViewStore would re-render on changes (to the store/scope they are using)?

  • I don't really love having to pass store down as an argument to every sub-view from the root of the app on down. In react there is a concept of Contexts that parents can hold, and then children much further down the line can pull values out of that context without being passed them directly as arguments. Im thinking of using Swift's Environment Values for this and wondering if this would for any reason be bad practice or would not work. (Or if there is a better parallel to React's Contexts for this).

The problem with @Environment and @EnvironmentObject, and the reason why TCA doesn't use it, is that it triggers a refresh on all the views which subscribe to it, which isn't good for performance. I tried to solve this by only triggering a refresh when only certain state, specified by the user, was mutated, but I wasn't able to find a solution.

I also, much like you, prefer using @ObservedObject over WithViewStore. Problem is it requires 5 lines of code compared to 2. I'm trying to solve this with a property wrapper on another thread, I'd love to hear your input!