Replies: 3 comments 5 replies
-
@GreatApe Thanks for taking the time to flesh out this post. I must admit I read it a couple times but I'm having a hard time understanding everything. If I understand correctly, it seems like the core of your idea is to make it so you only have to scope a single time, in the store layer, rather than also in the reducer layer. Please let me know if there's more to this that I'm missing or fail to address. There are two things that stand out to me that seem to get in the way of this goal:
We are definitely interested in the idea of tying store scoping to the child reducer that is embedded in the parent, but we haven't figured out a way to achieve this yet, nor do we know what kinds of benefits it would even unlock. We are also interested in what it would mean to "unify" the store and reducer into a single concept, but this is also a lofty goal and we don't fully understand the ramifications or design space. I encourage you to take some time to take your ideas a little further in the actual code base to vet them and see if/where they break down, or what new benefits they offer. We'd love to see what you find, but it's a little hard to grok in the abstract. |
Beta Was this translation helpful? Give feedback.
-
Thanks for the reply. Everything except the "Proposal" is just background, there is no real need to understand or even read that part. The whole idea is just as you say, to supply the child reducer directly to the child store. The goal of this is partly to avoid having to scope and define the hierarchy twice, but it also seems much more natural. For a long time I actually thought this is sort of how it works, and didn't even know I had to build up a reducer hierarchy. Anyway: ChildView(
store: store.scope(
state: \.child,
action: /ParentAction.child,
reducer: MyChildReducer()
)
) It doesn't change how we work with ViewStores etc, the difference is simply that just like the root store now receives a reducer, so would the child. Currently, a child store does see changes to its state coming from its ancestors, and actions coming from its descendants, but its own reducer is completely independent from this, it is triggered by its parent reducer, which is triggered by its parent, etc. The only its store is related to its reducer is by way of the root store. This means that we need to ensure that we replicate the entire chain of parent-child relationships that we specified in the view hierarchy, in the reducer, which doesn't feel very robust. And even if you copy the hierarchy correctly, you can run into problems when a child store is removed but the corresponding reducer receives a new action. A current limitation is that a parent reducer has to pick the exact child reducer to use. As far as the child store is concerned, it could handle any reducer with ChildState/ChildAction, and it seems that this is something that should be picked in the view, not the parent reducer - at least since this is how the root reducer is specified. So I definitely think the benefits of this are clear, the question is if it is impossible for some reason. When it comes to testing, one solution would be to simply provide the integration information in the test. This is extra work, but I don't think it's unreasonable. In my mind it's a bit strange that the reducer picks the exact type for the child. Also, when you think about it, since currently the Store/View system is disconnected from the reducers, there is really no guarantee that just because the reducer test works, that anything in particular would happen in the view. You may have changed the Store hierarchy so that it no longer matches the reducer hierarchy, a TestStore test would miss that. Regarding the other issue, could you provide an example where the store hierarchy has to, or could be, different from the reducer hierarchy? -- As to the loftier goal, one could perhaps imagine letting only the reducer handle the scoping and hierarchy part, and passing states and actions around, and having a singleton struct MyView: View {
@AppEngine var engine
public var body: some View {
// This store is scoped correctly and running it is handled in the
// reducer chain, inside the engine. There is no need for scoping here, and
// hence there is no need for the actual store, a viewstore is enough
// It should be possible to use the generic type as the "discriminant" like this,
// but worst case scenario we need to name the stores
let myViewStore: ViewStore<MyState, MyAction> = engine.viewStore()
Text(myViewStore.title)
if myViewStore.showChild {
let childStore: ViewStore<ChildState, ChildAction> = engine.viewStore()
ChildView(store: childStore)
}
}
} This seems closer to what we'd ideally want, but it's hard to know if it could be made to work. |
Beta Was this translation helpful? Give feedback.
-
I recently had a similar goal and I tried to solve it as a layer on top of TCA. As I explained in my recent article, I already have this /// A namespace for a TCA feature with extra requirements for Analytics and Error Handling.
public protocol Feature {
associatedtype State: FeatureState
associatedtype Action: FeatureAction
associatedtype Event: FeatureEvent
associatedtype Error: FeatureError
associatedtype Reducer: FeatureReducer
associatedtype View: FeatureView
}
/// A helper to declare a `Store` of a `Feature` type.
public typealias FeatureStore<F: Feature> = Store<F.State, F.Action> The The main reason for that was that usually in my views when constructing a So I tried to come up with an implementation of public typealias ParentStateOf<PF: ParentFeature> = ParentState<PF.State, PF.ChildState>
public typealias ParentActionOf<PF: ParentFeature> = ParentAction<PF.Action, PF.ChildAction>
//public typealias ParentReducerOf<PF: ParentFeature> = ParentReducer<PF.State, PF.ChildState, PF.Action, PF.ChildAction, PF.ChildReducer>
public struct ParentState<LocalState: Equatable, ChildState: Equatable>: Equatable {
public var local: LocalState
@PresentationState
public var child: ChildState?
public init(local: LocalState) {
self.local = local
}
}
public enum ParentAction<LocalAction: Equatable, ChildAction: Equatable>: Equatable {
case local(LocalAction)
case child(PresentationAction<ChildAction>)
}
public typealias ParentReducerOf<PF: ParentFeature> = ParentReducer<PF.LocalState, PF.LocalAction, PF.ChildState, PF.ChildAction, PF.Reducer, PF.ChildReducer>
public struct ParentReducer<LocalState: Equatable, LocalAction: Equatable, ChildState: Equatable, ChildAction: Equatable, LocalReducer: Reducer, ChildReducer: Reducer>: Reducer where LocalReducer.State == LocalState, LocalReducer.Action == LocalAction, ChildReducer.State == ChildState, ChildReducer.Action == PresentationAction<ChildAction> {
public typealias State = ParentState<LocalState, ChildState>
public typealias Action = ParentAction<LocalAction, ChildAction>
let childReducer: ChildReducer
let localReducer: LocalReducer
public init(
childReducer: ChildReducer,
@ReducerBuilder<LocalState, LocalAction> localReducer: () -> LocalReducer
) {
self.childReducer = childReducer
self.localReducer = localReducer()
}
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .local(let localAction):
return self.localReducer.reduce(into: &state.local, action: localAction)
.map { ParentAction.local($0) }
case .child(let childAction):
guard var childState = state.child else {
XCTFail("A 'Parent' received a child action when `child` state was set to a `nil`.")
return .none
}
return self.childReducer.reduce(into: &childState, action: childAction)
.map { ParentAction.child($0) }
}
}
}
//public struct ParentReducer<
// State: Equatable, ChildState: Equatable,
// Action: Equatable, ChildAction: Equatable,
// ChildReducer: FeatureChildReducer
//>: Reducer
//where ChildReducer.State == ChildState, ChildReducer.Action == ChildAction
//{
// public typealias State = ParentState<State, ChildState>
// public typealias Action = ParentAction<Action, ChildAction>
//
// public init() {}
//
// public var body: some ReducerOf<Self> {
// EmptyReducer().ifLet(\.$child, action: /ParentAction.child) { ChildReducer() }
// }
//}
public protocol ParentFeature where Reducer.State == LocalState, Reducer.Action == LocalAction, ChildReducer.State == ChildState, ChildReducer.Action == PresentationAction<ChildAction> {
// associatedtype State: ParentState<FeatureState, FeatureChildState>
// associatedtype Action: ParentAction<FeatureAction, FeatureChildAction>
associatedtype State: Equatable
associatedtype Action: Equatable
associatedtype Event: FeatureEvent
associatedtype Error: FeatureError
associatedtype Reducer: FeatureReducer
associatedtype View: SwiftUI.View
associatedtype LocalState: FeatureState
associatedtype LocalAction: FeatureAction
associatedtype ChildState: FeatureChildState
associatedtype ChildAction: FeatureChildAction
associatedtype ChildReducer: FeatureChildReducer
} But I couldn't get it to fully work and because I timebox these kinds of explorations, I had to stop in the middle (hence some code commented out). If I had succeeded, I would only use |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Abstract
Today we scope stores and reducers separately, and where state and action are funnelled up and down through the Stores, child reducers are controlled through the separate reducer hierarchy, meaning we have to specify all relationships twice. This seems unintuitive and error prone, so I suggest that we instead scope reducers where we scope stores, by simply supplying a child reducer to the child store when it is created.
This would simplify using TCA, make things more robust, and as a bonus it would decouple child reducers from parents.
Impatient readers can skip directly to Proposal at the end.
Introduction
Currently, when working with a child feature we need to essentially scope the parent world to the child world twice, both for the benefit of the view:
and for the benefit of the reducer:
This seems somewhat redundant, insofar as we essentially have to specify the relationship between parent and child twice. In the former case, when scoping the Store, we only have to provide unidirectional scoping transforms:
where on the reducer side both of the scopings have to be bidirectional:
Note also that if we don't add the Scope part in the parent reducer, state will still propagate down to the child store, and child actions will propagate up. So the reducer side scoping seems to only be necessary for the child reducer to be run.
Taking a step back, it seems we are dealing with 5 different entities that exist on both the parent level and the child level:
Now there are important differences in how these relate to their counterparts on the child level.
For views, parent and child are related through containment, the parent has child as a sub view.
For states and action the situation is also straightforward: they can look essentially however you want, and are completely user created.
Typically
ChildState
will be a property onParentState
, but it doesn't have to be like that, you just need to provide a mapping fromParentState
toChildState
.For actions, you need to provide a mapping in the other direction, and that typically consists of wrapping
ChildAction
in a case in aParentAction
enum.Stores, unlike all the other entities, are in one sense general, you don't define e.g. a
SettingsStore
, that would simply be an instance ofStore<SettingsState, SettingsAction>
, all you get to customize are the generic types. So a child store's relation to a parent store is defined by how its state and action pair are related to those of the parent.Reducers finally, are defined both by their associated types (Action and State), and by the actual definition, since these are user defined. Part of that definition describes how child reducers relate to the parent.
To complete the picture, we note that the thing that you instantiate and that kicks things off, is actually a store. It is created and given an initial state, as well as an instance of the root reducer.
Summary
Definitions:
scoping is used in the meaning "define how state/child state and action/child action relate, respectively"
hierarchy refers to things like
IfLetStore
/.ifLet
,ForEachStore
/.forEach
etcMy thoughts
A priori, it would seem that we should only have to specify the parent-child relationship once for the logic (and once for the views). It seems redundant that we have to do it both for the store and for the reducer. There also seems to be a slight redundancy between the scoping relationship and the hierarchical relationship, since if a child store has optional State, it sort of must be "if let" in some sense.
I think the main problem is related to Store, it feels like it’s not quite pulling its weight. On the one hand, you don’t get to create your own store type, you can just choose the state and action it uses. On the other hand, when you instantiate it you are very specific, you provide it with both state and the logic engine (reducer).
Then when you want to create a child store, the corresponding view will dictate what generic parameters to use for the child store. To create an instance of the child store, you then tell the parent how to scope its generic types (and hence its state), but you don’t do anything about the logic engine, which I suppose is why the child will interact correctly with the parent store out of the box, but any child reducer will of course not be run.
In fact, child the child store will know what generic parameters it would accept in a reducer, it doesn’t actually even know the type you want to use for reducer in the child, let alone have an instance of it. That has to be done separately, in the body of the parent reducer.
So to summarise:
The child gets:
In short, you define and create a store for the child and give it everything it needs in the view through
.scope
, except for its reducer.It seems to me that it could make sense to merge the Store and Reducer concepts in some way. I suppose the main reasons they are separate right now are:
Proposal
What would simplify the use of TCA would be if the reducer was scoped at the same time as the state/action pair is scoped. It seems to me that this should be achievable, perhaps it would be as simple as requiring users to supply the child reducer when they scope the store:
One bonus would be that not only would views remain decoupled from reducers, child reducers would be decoupled from the parent’s.
Would be very interesting to hear of any obvious shortcomings with this approach, or arguments against the idea in general.
Beta Was this translation helpful? Give feedback.
All reactions