@WrappedStore property wrapper to get rid of boilerplate

Ever since I started using TCA, WithViewStore always felt weird to me – it felt like I was infecting store logic into my view body properties, which I always like to keep clean and pure (viewStore.binding gives me the same sensation, but there's no getting rid of that :sweat_smile:). Paired with its weird behaviour with certain types of views like GeometryReader, I've diverted from using it, and have instead stuck to using a store property and an @ObservedObject var viewStore. This has been reinforced by the fact that isowords also prefers to use this method over WithViewStore.

However, the two properties + custom initialisation, although nothing which can't be solved with a Sourcery template, feels cumbersome and repetitive. That's why I created the simple @WrappedStore property wrapper to get rid of this boilerplate. It looks like this:

@propertyWrapper
public struct WrappedStore<State, Action>: DynamicProperty {
    public let wrappedValue: Store<State, Action>
    
    @ObservedObject public var projectedValue: ViewStore<State, Action>
    
    public init(wrappedValue store: Store<State, Action>) where State: Equatable {
        self.init(wrappedValue: store, removeDuplicates: ==)
    }
    
    public init(wrappedValue store: Store<State, Action>, removeDuplicates isDuplicate: @escaping (State, State) -> Bool) {
        self.wrappedValue = store
        self.projectedValue = ViewStore(store, removeDuplicates: isDuplicate)
    }
}

Now, views can look like this:

struct ContentView: View {
    @WrappedStore var store: Store<AppState, AppAction>
    
    var body: some View {
        ChildView(store: store.scope(state: \.childState, action: AppAction.childAction))
    }
}

struct ChildView: View {
    @WrappedStore var store: Store<ChildState, ChildAction>
    
    var body: some View {
        VStack {
            TextField("String", text: $store.binding(get: \.text, send: ChildAction.setText))
            Button("Increment Counter: \($store.counterState)") {
                $store.send(.increment)
            }
        }
    }
}

I wish the Store could be the projectedValue, and ViewStore the wrappedValue (since it's used more), but then I wouldn't be able to have the store: initialiser – I'm open to solutions though. And of course, the name WrappedStore is open to bikeshedding.

Would love to hear what @stephencelis and @mbrandonw think about this!

2 Likes

This is really cool!

One thing I can think of that could improve this is to allow for the property wrapper to accept a keypath or closure parameter that lets you scope on a specific piece of child state that's intended to be what the viewstore listens to. Otherwise any state change (even changes that don't concern the view in question) could cause a re-render which might introduce performance/ui bugs.

If I could possibly do something like this, that would be a great addition to this technique:

struct MyState: Equatable {
  ...
  struct ViewState {
    ...
  }
  var view: ViewState {
    ...
  }
}

struct MyView: View {
  @WrappedStore(viewState: \.view) var store: Store<MyState, MyAction>
  
  var body: some View {
    /* $store is of type ViewStore<MyState.ViewState, MyAction>
       and only ViewState changes are checked for view updates to this view */
  }
}

The keypath parameter could be optional and default to \.self if you don't need that sort of precision in what state you listen to, perhaps.

1 Like

FWIW it's not that we have a preference. We have plenty of usages of WithViewStore in the code base. We usually opt for the @ObservedObject when the view is complicated enough that autocomplete breaks down. There's something about SwiftUI views whose content is expressed as a closure with an argument that stresses the compiler. I experience this with GeometryReader and ScrollViewReader where the proxy argument often doesn't have any autocomplete. There's a chance that Xcode 12.5 has improved this.

Your @WrappedStore property wrapper is interesting, but ultimately it blurs the line between Store and ViewStore in a way that is not appropriate for TCA. One of the primary purposes of the ViewStore is to chisel away the state to the bare essentials a view needs to do its job. This can be important for the performance of some views. We have lots of examples of this in isowords and lots of episodes on this topic (and more coming in a few weeks).

With the way @WrappedStore is designed now you would always observe all state in the store every time you use it in a view, even if you only need a small piece of the state to actually display the view.

Stephen and I played with properties quite a bit to try to meld the concepts of Store and ViewStore in a way that preserved the utility of view stores and the ergonomics of the dedicated ViewStore type, but we could never figure it out. I believe that property wrappers are missing a few features that would make it possible. Essentially, we'd love if @Kevin_Lundberg's proposed syntax could work, but we're not sure how.

1 Like

AFAIU, @WrappedStore behaves the exact same way as using a store property and an @ObservedObject var viewStore.
DynamicProperty works in the same way as View in that it only triggers a reload when either a value type property is mutated, or it receives an objectWillChange signal – there's no way for it to know that a reference type was mutated otherwise.

I tested the view reload count using both methods – you can find the gist with the code here.

This is when using @WrappedStore:

And this is when using the store property / @ObservedObject var viewStore:

So, @WrappedStore is equivalent when you're just scoping the store down each view.

I agree, though, that right now we can't observe only a certain ViewState when using @WrappedStore, although I'm working on it right now.

Firstly, it would have to be a new property wrapper, since it introduces a third generic type for the new local state, and I wouldn't want to force the user of @WrappedStore to specify a third generic type which would be equivalent to the first. Let's call this property wrapper @WrappedStoreLocal.

The main issue is that is needs to phases of initialisation: one where the user passes in the closure/keypath which would convert parent state into the local state, and another where the caller passes in the store. This is simply not possible right now with property wrappers – here's a gist with my best try at an implementation of @WrappedStoreLocal.

I'd love to hear any solutions for this!

Yeah this is true. If you are observing the full state of the store, then @WrappedStore is equivalent to @ObservedObject var viewStore. But we feel that observing all of the state in a store should be an infrequent thing. The only time it's probably appropriate is when you are a leaf view node or close to a leaf.

Now having said that, if your application is humming along fine without being super prescriptive with what state is being observed in the view store, and you are not seeing performance problems then it is seems completely fine to use the @WrappedStore property wrapper. It's just not something we will bake into the library unless we can figure out how to recover the full functionality of ViewStore in it.

2 Likes

I completely agree – making a custom ViewState struct for the view should be the default for performance. I guess I'll only use @WrappedStore when I'm just doing a simple store.scope(state:action), and then revert back to @ObservedObject var viewStore when I need a custom ViewState.

I'll keep on working on @WrappedStoreLocal though! Ultimately, I want it to look like this:

struct MyView: View {
    struct ViewState: Equatable {
        // ...
        init(state: AppState) { /* ... */ }
    }
    
    @WrappedStoreLocal(viewState: ViewState.init) var store: Store<AppState, AppAction>

    var body: some View {
        // ...
    }
}

struct ParentView: View {
    @WrappedStore var store: Store<AppState, AppAction>

    var body: some View {
        MyView(store: store)
    }
}

Please let us know what you find! That theoretical syntax is about what we want too. If we can achieve it we would even consider renaming it to something like @ViewStore(state:action:) var store: Store<...> :grimacing:

1 Like
@propertyWrapper
public struct ViewStore<State, Action, LocalState>: DynamicProperty {
  public let wrappedValue: Store<State, Action>
  
  @ObservedObject public var projectedValue: ViewStore<LocalState, Action>
    
  public init(
    wrappedValue store: Store<State, Action>,
    state toLocalState: @escaping (State) -> LocalState
  ) where State: Equatable, LocalState: Equatable {
    self.init(wrappedValue: store, state: toLocalState, removeDuplicates: ==)
  }
  
  public init(
    wrappedValue store: Store<State, Action>,
    state toLocalState: @escaping (State) -> LocalState,
    removeDuplicates isDuplicate: @escaping (LocalState, LocalState) -> Bool
  ) {
    self.wrappedValue = store
    self.projectedValue = ViewStore(store.scope(state: toLocalState), removeDuplicates: isDuplicate)
  }
}

Sadly, this:

@ViewStore(state: ViewState.init) var store: Store<AppState, AppAction>

doesn't work yet:

Missing argument for parameter 'wrappedValue' in call

There is a pitch to Allow Property Wrappers with Multiple Arguments to Defer Initialization when wrappedValue is not Specified.

1 Like

Yep, that's exactly what I was looking for. Seems like many people want this, and according to @hborla shouldn't be incredibly difficult to implement (then again, I'm not a language/compiler dev, so what do I know :sweat_smile:). I made a small post on that thread to reignite the discussion, thanks a lot!

2 Likes