Using a protocol instead of an enum for state #3817
-
|
We have a situation that might be outside of the realm that TCA is designed for, but I have been mostly successful in getting it to work... Specifically, we have an extensability requirement in our underlying model that allows new variants of many components in the model to be added by using plugins. So, in places where TCA would naturally use an enum state, we need to use a different technique. I have been able to get it to work by having all of the TCA components conform to protocols, but I have encountered a situation where TCA crashes, and I would like to offer a change to TCA. (As an aside, I have been programming for over 60 years, but this is my first contribution to Open Source software in over 50 years, and the process was very different then. I not completely sure about how to proceed.) To demonstrate the issue, I have created a toy application. This runs on macOS. The code that almost works is in Icon4. Each component in the model comes with a structure that defines closures to implement the code required for TCA: protocol Icon4State {
var icon: Icon4 { get }
}
protocol Icon4Action {
}
struct Icon4Type {
let makeInitialState: (Icon4) -> any Icon4State
let view: (StoreOf<Icon4Reducer>,
@escaping (Icon4Reducer.State) -> Icon4State,
@escaping (Icon4Action) -> Icon4Reducer.Action) -> AnyView
}and each specific implementation supplies those closures, for example: extension Icon4Pict {
private static var _iconType = Icon4Type(
makeInitialState: { Pict4Reducer.State(icon: $0) },
view: { store, childState, parentAction in
return AnyView(
Pict4View(
store: store.scope(
state: { state in childState(state) as! Pict4Reducer.State },
action: parentAction as (Pict4Reducer.Action) -> Icon4Reducer.Action // 'scope(state:action:)' is deprecated
)
)
)
}
)
}For simplicity we can type-erase the State and Action: struct AnyIcon4State {
var state: any Icon4State
func view(store: StoreOf<Icon4Reducer>) -> AnyView {
state.icon.view(store: store)
}
}
struct AnyIcon4Action {
var wrapped: any Icon4Action
}And then we can handle all variants with @Reducer
struct Icon4Reducer {
@ObservableState
struct State {
var child: AnyIcon4State
}
@CasePathable
enum Action {
case iconClicked(Icon4)
case child(any Icon4Action)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .iconClicked(let icon4):
state.child = AnyIcon4State(state: icon4.makeInitialState())
return .none
case .child:
// TODO: Invoke the child reducer with Icon4State and Icon4Action
return .none
}
}
}
}and this code almost works. It should be noted that using the
The issue I've encountered is that that closure also crashes when changing the state, because the forced cast in the closure is casting the new state, which is not the same as the old state. Looking into the TCA source code, I noticed that when using Well, that's a solution, but I don't think we should always be caching the previous state in
|
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 1 reply
-
|
Hey @larryatkin, Thanks for taking the time to dig into the problem and propose a solution. This seems to be a tricky situation, so let me start with some context:
Early versions of TCA did not have stable store identity: every time you scoped a store it would create a brand new object and set up a subscription with the parent store to synchronize. Over time, TCA evolved to keep child stores around in the parent, but we needed a stable, hashable way of identifying a store. Closures are not hashable and so we cannot compute a stable key for which the child can live. The plan is to completely remove closure-based scoping in the next major version of TCA, so we would need a different solution here than the proposed PR.
Unfortunately not, as children are not being cached in the store’s dictionary.
I think it actually is possible for you to define a key path and a case key path here. It's an advanced use case so the current solution I managed to come up with gets into the weeds a bit, but let's see if you can employ it: // An example of a protocol-based domain:
protocol ChildState: ObservableState {}
protocol ChildAction: CasePathable {}
// An implementation of the domain:
@Reducer
struct Child {
@ObservableState
struct State: ChildState {}
enum Action: ChildAction {}
}
// A parent domain holding child domain as existentials:
@Reducer
struct Parent {
struct State {
var child: any ChildState
}
enum Action {
case child(any ChildAction)
}
}
// A trivial hashable type to allow key path subscripts to be hashable.
struct HashableType<T>: Hashable {
init(_: T.Type) {}
static func == (lhs: Self, rhs: Self) -> Bool { true }
func hash(into hasher: inout Hasher) {}
}
// A key path subscript for scoping into child state.
extension ChildState {
subscript<T: ChildState>(as _: HashableType<T>) -> T {
self as! T
}
}
// A case key path subscript for scoping into child actions.
//
// This type is typically an implementation detail of CasePaths,
// but can be extended in advanced cases like this one to derive
// case key paths.
extension Case<any ChildAction> {
subscript<T: ChildAction>(as _: HashableType<T>) -> Case<T> {
Case<T>(embed: { $0 as any ChildAction }, extract: { $0 as? T })
}
}
// An example scoping:
func example(_ store: StoreOf<Parent>) {
store.scope(
state: \.child[as: HashableType(Child.State.self)],
action: \.child[as: HashableType(Child.Action.self)]
)
}It's a lot, but hopefully it unblocks you. I've only explored the store aspect of things, so more work will need to be done in the reducer layer to embed the child business logic in the parent domain. We'll have to consider if there are better ways for us to allow folks to define custom kinds of scoping that play nicely with store/reducer identity, either by exposing the underlying "core" functionality in a public way, or perhaps with a |
Beta Was this translation helpful? Give feedback.
Hey @larryatkin,
Thanks for taking the time to dig into the problem and propose a solution. This seems to be a tricky situation, so let me start with some context:
Early versions of TCA did not have stable store identity: every time you scoped a store it would create a brand new object and set up a subscription with the parent store to synchronize. Over time, TCA evolved to keep child stores around in the parent, but we needed a stable, hashable way of identifying a store. Closures are not hashable and so we cannot compute…