How can The Composable Architecture really become composable?

This thread is a continuation of IfLetStore and Effect cancellation on view disappear - #12 by hfossli, but covers more aspects of the issues discussed.

My understanding is that TCA is underdelivering on its promise to be composable. TCA is great for creating tree of reducers, state trees, scoping reducers etc, but problems arise if you want to reuse views and reducers. I think reuse is an important aspect of composition.

How does TCA underdeliver on "reuse"?

In our app we have some basic views we reuse a lot. They mainly look like this

let avatarReducer = Reducer<AvatarState, AvatarAction, Void> { state, action, env in
    
    struct Cancellation: Hashable {}

    switch action {

    case .onAppear:
        return env.load(userId: state.userId)
            .map { AvatarAction.loaded($0) }
            .replaceError(with: AvatarAction.failed(.network))
            .eraseToEffect()
            .cancellable(id: Cancellation())
        
    case .loaded(let name):
        state.image = UIImage(systemName: name)
        return .none
        
    case .failed(let error):
        state.error = error
        return .none
            
    case .onDisappear:
        return .cancel(id: Cancellation())
    }
}

We have many of these AvatarViews on the screen at the same time. Our AvatarViews may appear anywhere in the view tree and reducer tree. We do a lot of pullback with optional reducers (like when a user is navigation to a detail view).

As you can see the AvatarView is itself responsible for loading its data – in the onAppear-case we are starting an async effect. We don't know how long time it may take. It is very important that the AvatarView is cancelling its ongoing effect when it is removed from the view tree (or the reducer tree).

Since we have many AvatarViews on the screen at the same time we don't want them to be in conflict of each other. To avoid one avatarReducer to unintentionally cancel other avatarReducers we pass a cancellation id from the outside and in like this:

+ struct AvatarEnvironment {
+     var cancellationId: AnyHashable
+ }

- let avatarReducer = Reducer<AvatarState, AvatarAction, Void> { state, action, env in
+ let avatarReducer = Reducer<AvatarState, AvatarAction, AvatarEnvironment> { state, action, env in
    
-   struct Cancellation: Hashable {}
    
    switch action {

    case .onAppear:
        return env.load(userId: state.userId)
            .map { AvatarAction.loaded($0) }
            .replaceError(with: AvatarAction.failed(.network))
            .eraseToEffect()
-           .cancellable(id: Cancellation())
+           .cancellable(id: env.cancellationId)
        
    case .loaded(let name):
        state.image = UIImage(systemName: name)
        return .none
        
    case .failed(let error):
        state.error = error
        return .none
            
    case .onDisappear:
-       return .cancel(id: Cancellation())
+       return .cancel(id: env.cancellationId)
    }
}
let contactReducer = Reducer<ContactState, ContactAction, Void>.combine(
    avatarReducer.pullback(
        state: \.me,
        action: /ContactAction.me,
        environment: { env in
            struct Cancellation: Hashable {}
            return AvatarEnvironment(cancellationId: Cancellation())
        }
    ),
    avatarReducer.pullback(
        state: \.peer,
        action: /ContactAction.peer,
        environment: { env in
            struct Cancellation: Hashable {}
            return AvatarEnvironment(cancellationId: Cancellation())
        }
    ),
    Reducer { state, action, env in
        ...
    }
}

Now these AvatarViews may be used all over the place by FriendsView, ProfileView and ContactView. There might be multiple instances of ContactView at the same time so ContactView also needs to get a cancellation id from the outside.


+ struct ContactEnvironment {
+     var cancellationId: AnyHashable
+ }

- let contactReducer = Reducer<ContactState, ContactAction, Void>.combine(
+ let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironment>.combine(
    avatarReducer.optional().pullback(
        state: \.me,
        action: /ContactAction.me,
        environment: { env in
            struct Cancellation: Hashable {}
-           return AvatarEnvironment(cancellationId: Cancellation())
+           return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
        }
    ),
    avatarReducer.optional().pullback(
        state: \.peer,
        action: /ContactAction.peer,
        environment: { env in
            struct Cancellation: Hashable {}
-           return AvatarEnvironment(cancellationId: Cancellation())
+           return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
        }
    ),
    Reducer { state, action, env in
        ...
    }
}

PS: Combining cancellation ids with array's is a nice way of making sure they are unique and scoped properly.

Now we are closing in on the heart of the problem. The ContactView is optional

struct DetailState: Equatable {
    var contactState: ContactState?
}

Whenever an AvatarView is removed the AppAction.detail(.me(.onDisappear)) case and AppAction.detail(.peer(.onDisappear)) should be reduced, but it is too late, the contactState has been nilled out and without state we can not reduce. This leads to a crash :boom:

In this thread IfLetStore and Effect cancellation on view disappear - #11 by darrarski we look at solutions for this specific issue, but as of today no solution has come up that is sufficient in my eyes.

How can we make TCA better support reuse?

Sorry for the long introduction, but this issue should be looked at holistically. I think these points should be considered when discussing solutions

  • TCA should always assume views and reducers are reused
  • TCA should always assume reducers needs to stop ongoing effects
  • Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"
  • There should be a minimum of pitfalls and gotchas to learn

Gotcha 1 – Forgetting to cancel one or more underlying effects

This is the most scary one as it will cause your app to crash if you don't pay close attention in code review or when writing code.

Example: ContactView has 2 AvatarViews. The developer remembers to cancel just one of them.

This will be difficult to find, discover and debug as it is a timing issue. It will happen some times for some users in production because maybe you have a network call that is completed in most cases before cancellation is happening?

Gotcha 2 – Forgetting to create a unique cancellation

I have outlined one way for developers to create unique cancellation ids by passing them in from outside, but if developers forget to do this they will suddenly find that reducers and views are unintentionally affecting each other when it comes to cancellation.

Example: Removing AvatarView 1 cancels ongoing effects in AvatarView 2.

Gotcha 3 – Cancellation id's might get mistyped because of retyping

If developers need to retype construction of cancellation ids there's a big chance they mistype or get out of sync.

Gotcha 4 – There's more to cancel down the chain

A view might use several other views and reducers which also needs to cancel their long going effects. If a reducer/view is not able to cancel their own effects and it must be done from the outside a long chain of cancellation ids must be constructed and cancelled. This can

Example: Cancelling all underlying effects here might be error prone

DetailView 
  -cancel-> ContactView 
     -cancel-> AvatarView instance 1
        -cancel-> AvatarFavoriteColorView
     -cancel-> AvatarView instance 2
        -cancel-> AvatarFavoriteColorView

Suggestions

I don't think I have the perfect answer here. This is quite complicated and it is quite complicated to find solutions within the current structure of the framework (to me at least).

Alt 1: Doing tear down via ViewStore

If the Store knew which long living effects it produced then maybe we could cancel all of those?

Example

struct DetailView: View {
    let store: Store<DetailState, DetailAction>

    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                AvatarView(
                    store: self.store.scope(
                        state: \.me,
                        action: { .me($0) },
                        cancellationId: [store.cancellationId, "me"]
                    )
                )
                Text("Me").font(.title)
            }.onAppear {
                viewStore.send(.onAppear)
            }.onDisappear {
-               viewStore.send(.onDisappear)
+               viewStore.cancelAll() 
            }
        }
    }
}

I have a proof of concept of this here

and a draft here

Cons: Separation of cancellation id on store and environment+reducers is probably weird looking?

Pros: Not a lot required of the developers. Easy to tear down a whole host of cancellation ids at once.

Alt 2: Remove the global cancellation array

Today, cancellation is happening via a global singleton!

var cancellationCancellables: [AnyHashable: Set<AnyCancellable>] = [:]

I don't like it because of all the gotchas it introduces. I would rather have that singleton usage explicit like this

- Effect.cancellable(id: env.cancellationId)
+ Effect.cancellable(id: env.cancellationId, bag: CancellationBag.global)

This could open some doors. When we create cancellation bags that could be passed down the chain we can cancel a whole host of id's at once like this

Effect.cancel(bag: env.cancellationBag)

Alt 3: Environments can return all cancellable ids

If all environments can return all sub environments then we can make them all conform to returning cancellation ids in the whole tree

Example:

struct DetailEnvironment {
    var cancellationId: AnyHashable
    
    var cancellationIds: [AnyHashable] {
        return [cancellationId] + meEnv.cancellationIds + peerEnv.cancellationIds
    }
    
    var meEnv: AvatarEnvironment {
        struct Cancellation: Hashable {}
        return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
    }
    
    var peerEnv: AvatarEnvironment {
        struct Cancellation: Hashable {}
        return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
    }
}

struct AvatarEnvironment {
    var cancellationId: AnyHashable
    
    var cancellationIds: [AnyHashable] {
        return [cancellationId]
    }
}

Then when doing pullbacks we could avoid repeating ourselves and do

    avatarReducer.pullback(
        state: \.peer,
        action: /DetailAction.peer,
        environment: { env in
-           struct Cancellation: Hashable {}
-           return AvatarEnvironment(cancellationId: Cancellation())
+           return env.peerEnv
        }
    ),

And finally when we want to nil out a optional state-tree we can clear all cancellation id's

case .closedDetailView:
   return Effect.concatenate(env.cancellationIds.map { 
       Effect.cancel(id: $0) 
   })

Cons: More code? Easy to forget some cancellation id's.

Alt 4: Optional stores cancels automatically

I believe some solution like

- myReducer.optional()
+ myReducer.optional(cancellationBag: env.cancellationBag)

would be beneficial. All pending effects could be cancelled at the moment the state is nilled out (or next time it tries to reduce an optional state for that cancellation-id/bag.

Alt 5: Life cycle functions

In this thread IfLetStore and Effect cancellation on view disappear - #2 by mbrandonw a lifecycle concept is discussed. It works for optional reducers, but not for optional reducers with non optional children. Maybe this concept could be made to support that? I don't know.

Some other solution ??

I don't know if this is relevant, maybe there's some work going on here? https://github.com/pointfreeco/swift-composable-architecture/compare/main...apple-cancellation

Way forward

I urge @stephencelis and @mbrandonw to have a look at this. They probably see the whole picture and know ins and outs of TCA. This is an important step in making TCA more mature and composable.

I have spent quite a lot of time now on this issue and I hope we together can save more sunk time on this.

22 Likes

I have not heard anything from anyone. I think the best solution going forward is alternative 1

SomeView().onDisappear {
     viewStore.cancelAll() 
}

That is something the authors of TCA would have to approve. I still have hope :slight_smile:

Workaround: CancellationBag

In the meantime while waiting for TCA authors I have created a workaround I am happy with – which I also think wold be a good pull request for TCA. You'll find the PR here!

A CancellationBag is a class that has the ability to cancel a collection of cancellation id's

public final class CancellationBag {
   ...
   public static func bag(id: AnyHashable) -> CancellationBag 
   public func cancelAll()   // cancel all cancellables
   public func cancel(id: AnyHashable) // cancel one cancellable
}

Your environment can by default create the bag like this

struct AvatarEnvironment {
    struct BagID: Hashable { }
    var bag = CancellationBag.bag(id: BagID())
}

Our aim is to reuse and have multiple instances of AvatarEnvironment at the same time so the owner of the AvatarView may override the environment

struct DetailEnvironment {
    struct BagID: Hashable { }
    var bag = CancellationBag.bag(id: BagID())
    
    var me: AvatarEnvironment {
        struct BagID: Hashable { }
        return AvatarEnvironment(bag: CancellationBag.bag(id: BagID()).asChild(of: bag))
    }
    
    var peer: AvatarEnvironment {
        struct BagID: Hashable { }
        return AvatarEnvironment(bag: .bag(id: BagID()))
    }
}

This seems like a mouthfull so it can be simplified into

struct DetailEnvironment {
    var bag = CancellationBag.autoId()
    var me: AvatarEnvironment { .init(bag: .autoId(childOf: bag)) }
    var peer: AvatarEnvironment { .init(bag: .autoId(childOf: bag)) }
}

by using a simple extension

extension CancellationBag {
  public static func autoId(childOf parent: CancellationBag? = nil, file: StaticString = #file, line: UInt = #line, column: UInt = #column) -> CancellationBag
}

This extension creates a unique AnyHashable based on file, line number and column number of caller.

Further, now that we have a bag on our environment, we can now start using this CancellationBag.

First place is in the side effects we initiate.

    case .onAppear:
        struct Cancellation: Hashable {}
        return Publishers.Timer(
            every: 1,
            tolerance: .zero,
            scheduler: DispatchQueue.main,
            options: nil
        )
        .autoconnect()
        .catchToEffect()
-       .cancellable(id: .Cancellation())
+       .cancellable(id: .Cancellation(), bag: env.bag)
        .map { _ in DetailAction.timerTicked }

Secondly, to the optional reducer we can now specify a CancellationBag

    detailReducer.optional(cancellationBag: { $0.bag }).pullback(
        state: \.detail,
        action: /AppAction.detail,
        environment: { $0.detail }
    ),

The optional reducer will then automatically cancel all ongoing side effects if the state is nil

extension Reducer {
    public func optional(cancellationBag: @escaping (Environment) -> CancellationBag) -> Reducer<
        State?, Action, Environment
    > {
        .init { state, action, environment in
            guard state != nil else {
                cancellationBag(environment).cancelAll()
                return .none
            }
            return self.run(&state!, action, environment)
        }
    }
}

So basically when you try to do this...

    MyView().onDisappear {
        viewStore.send(.onDisappear)
    }

...then it will just workℱ! Because if the reducer is in an optional and nilled out state tree the cancellation is happening for all cancellation bags down the chain whenever an action is sent. And further if the reducers state is not nilled out it will be able to cancel by itself by doing

    case .onDisappear:
        return .cancelAll(bag: env.bag)
    }

:sunny:

I think this solution covers these considerations:
:white_check_mark: TCA should always assume views and reducers are reused
:white_check_mark: TCA should always assume reducers needs to stop ongoing effects
:white_check_mark: Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"
:white_check_mark: There should be a minimum of pitfalls and gotchas to learn

Summary about CancellationBag

In essence all you need to know is that you can specify a cancellation bag to the optional reducer

reducer.optional(cancellationBag: { $0.bag })

you can scope your cancellable effects

effect.cancellable(id: Cancellation(), bag: env.bag)

and you can can create an overridable cancellation bag for your environment like this

struct AvatarEnvironment {
    var bag = CancellationBag.autoId()
}

A working demo of the solution can be found here:

Let me know your thoughts :blush:

4 Likes

Also, the workaround can be simply dragged and dropped into your project, you only need this file.

1 Like

Really nice approach when combined with the optional reducer !
How many times did I had to implement some complex cancellation when swiping back

2 Likes

How many times did I had to implement some complex cancellation when swiping back

Yeah, right? :joy:It quickly gets messy.

1 Like

That is quite an elegant solution, thank you for sharing!

@hfossli I was wondering if you'd seen this: add presents · pointfreeco/swift-composable-architecture@dc4b49d · GitHub

1 Like

Haven't seen it. I don't fully understand it. How can it be useful?

It doesn't cover every use case but it covers the general use case of "show some view when some optional state is present, then hide it when that state becomes nil", but automatically cancelling any effects that were started and are still running. It would seem to solve your .onDisappear issue (which is fired too late because the state is now nil) in a really elegant way without needing the lifecycle high level reducer.

As far as I can tell, instead of doing this:

let parentReducer = Reducer<ParentState, ParentAction, ParentEnv>.combine(
  childReducer
    .optional()
    .pullback(state: \.child, action: /ParentAction.child)
)

You would do this:

let parentReducer = Reducer<ParentState, ParentAction, ParentEnv> = { _, _, _ in
  // parent reducer logic
}
.presents(
  childReducer,
  cancelEffectsOnDismiss: true,
  state: \.child,
  action: /ParentAction.child,
  environment: { _ in ChildEnv() }
)
4 Likes

@hfossli Is this file GPL 3.0 licensed? As it solves real problems and probably will help a lot of people I would consider using MIT License like TCA itself :)

It is hereby MIT

1 Like

@lukeredpath I have had a look at that solution now. It seems very promising! In December I tried to implement a similar solution, but was struggling with putting the pieces together. What is that iso-branch? Is it planned to be merged with main-branch?

It seems that solution ticks off the following criteria I think is important

:white_check_mark: TCA should always assume views and reducers are reused

yes, but it is up to user to implement with cancellationId's in env

:white_check_mark: TCA should always assume reducers needs to stop ongoing effects

Using the presents function solves this nicely

:white_check_mark: Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"

Yes, but it is up to the user to add and pass down cancellationId's to the environments

:white_check_mark: There should be a minimum of pitfalls and gotchas to learn

The only gotcha so far I found is that devs need to pass down cancellation ids to avoid collision when reusing components. Maybe some examples in the readme will help mitigate bad practice.

To recap for all readers you can add this extension to your codebase

import ComposableArchitecture

extension Reducer {
  
  fileprivate func nonCrashingOptional(_ file: StaticString = #file, _ line: UInt = #line) -> Reducer<
    State?, Action, Environment
  > {
    .init { state, action, environment in
      guard state != nil else {
        return .none
      }
      return self.run(&state!, action, environment)
    }
  }
  
  public func presents<LocalState, LocalAction, LocalEnvironment>(
    _ localReducer: Reducer<LocalState, LocalAction, LocalEnvironment>,
    cancelEffectsOnDismiss: Bool,
    state toLocalState: WritableKeyPath<State, LocalState?>,
    action toLocalAction: CasePath<Action, LocalAction>,
    environment toLocalEnvironment: @escaping (Environment) -> LocalEnvironment
  ) -> Self {
    let id = UUID()
    return Self { state, action, environment in
      let hadLocalState = state[keyPath: toLocalState] != nil
      let localEffects = localReducer
        .nonCrashingOptional()
        .pullback(state: toLocalState, action: toLocalAction, environment: toLocalEnvironment)
        .run(&state, action, environment)
        .cancellable(id: id)
      let globalEffects = self.run(&state, action, environment)
      let hasLocalState = state[keyPath: toLocalState] != nil
      return .merge(
        localEffects,
        globalEffects,
        cancelEffectsOnDismiss && hadLocalState && !hasLocalState ? .cancel(id: id) : .none
      )
    }
  }
}

Then replace optional + pullback with presents

let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
    Reducer { state, action, env in
        ...
    }.presents(
        detailReducer,
        cancelEffectsOnDismiss: true,
        state: \.detail1,
        action: /AppAction.detail1,
        environment: { env in
            DetailEnvironment(cancellationId: [env.cancellationId, 1])
        }
    )
)

Make sure the environment is getting a unique cancellation id by passing down the chain

struct AppEnvironment {
    let cancellationId: AnyHashable
}

struct DetailEnvironment {
    var cancellationId: AnyHashable
}

It can be done with arrays

environment: { env in
    AvatarEnvironment(cancellationId: [env.cancellationId, "me"])
}
...
environment: { env in
    AvatarEnvironment(cancellationId: [env.cancellationId, "peer"])
}

...or inline creation of structs

environment: { env in
    struct CancelId: Hashable {}
    return AvatarEnvironment(cancellationId: [env.cancellationId, CancelId()])
}
...
environment: { env in
    struct CancelId: Hashable {}
    return AvatarEnvironment(cancellationId: [env.cancellationId, CancelId()])
}

...or by using an extension like AutoID.

A working example can be found here https://github.com/hfossli/tca-ifletstore-effect-cancellation-demo/pull/1/files.


A note on the cancellation bag solution

It seems excessive now that there's a less invasive workaround.

Also, there's an error in my code examples. You shouldn't use .cancelAll(bag: env.bag) in `onDisappear as it may cancel ongoing effects in detail view controllers you push into the screen (which may be children of your parent cancellation bag). @Cierpliwy @nmccann @AlexisQapa

Make sure to cancel only the cancellation ids for your own reducer on onDisappear (not the whole bag):

let myReducer = Reducer<State, Action, Environment> { state, action, env in

    enum CancellationID: Hashable, CaseIterable {
        case loadArticleList
        case saveToDisk
    }

    case .onAppear:
        return .env
            .loadArticles()
            .cancellable(id: CancellationID.loadArticleList, bag: env.bag)
    
    case .saveToDisk:
        return .env
            .saveToDisk()
            .cancellable(id: CancellationID.loadArticleList, bag: env.bag)

    case .onDisappear:
        return .cancel(ids: CancellationID.allCases, bag: env.bag)
}

Eaxmple can be found here https://github.com/hfossli/tca-ifletstore-effect-cancellation-demo/pull/2.

Good to see it ticks quite a few boxes. FWIW, I definitely think some of your suggested improvements around cancellation are worth pursuing.

I'm going to take a guess that the iso branch is what @stephencelis and @mbrandonw are using for their isowords game and it seems to be mostly tracking main but it does have some interesting refactoring around optional paths taken from this branch, which seems to generalise KeyPath and CasePath and offer first-class support for enum-based state when using pullback:

I actually have a use-case for this on a current project so I'm going to experiment with this, although you can work around the problem by adding computed-properties to your enums to expose their associated values.

1 Like

Very interesting. Thanks for sharing. Very valuable. :heart:

Hey,

I've just come across this same problem and put the presents higher order reducer into my project which solved the issues I was having straight away.

I had a question about what the cancellationID is and should be used for though. Should I be using environment.cancellationID for all my cancellable effects? What if I have multiple cancellable effects in a reducer?

As an example, I created an Effect extension that wraps up Firestore's addSnapshotListener function. This is a long lived subscription function. Part of this function takes a cancellationID.

Now, if in one reducer I subscribe to a UserDoc and also a TimelineDoc (for example). I might want to cancel one or the other but not both of these

Would you suggest using a separate cancellationID for each? I guess it would HAVE to do that. So I could use [env.cancellationID, "user"] and [env.cancellationID, "timeline"] for each respectively I guess?

N.B. In my current implementation I'm creating a struct UserDocSubscriptionID: Hashable {} and struct TimlineDocSubscriptionID: Hashable {} to do this.

I guess, ideally, it would be better to break out the TimelineView into it's own child reducer and have it's own cancellation ID, etc... :thinking:

Lastly...

Let's say I have set up these long lasting effects from my TimelineReducer which is optional state and could be "nilled" at any time. It runs the .cancel(id: id) in the presents higher order reducer. But would that actually cancel the long lived effects that I have in the TimelineReducer. Do I need to make sure I'm cancelling them myself? But, if I cancel them in an action by returning an effect is there a rick of the action not being run due to the state being nil?

Sorry for the deluge of questions. I feel like I'm on the brink of solving all my problems with this I've just got a few niggles with it which are probably due to not understanding it fully.

Thanks :+1:

Thanks for pointing this out! I really hope this makes it into the main branch, as it solves one of my biggest annoyances with optional state in the Composable Architecture.

Something that this doesn't resolve is the case where you replace (rather than nil out) the state, for example:

state.child = Child()
//Any long-lived effects from the previous child state are still running, and may cause unexpected behaviour

I don't know if there is a good way to handle this in a similar manner to presents, or if it's just a matter of education, and knowing that your child reducer should guard against this behaviour.

As a concrete example, I have a child reducer that checks it's state when a value is loaded:

case .loaded(let value):
guard state.isLoading else {
return .none
}

state.isLoading = false
state.loadedValue = value

The above would ignore loaded actions if it wasn't expecting them. But it's unfortunate that I have to consider this at all.

Also it may be cool if we could share substates between states. Something like this

struct StateA {
    var username: String
    var value: Int
}

struct StateB { 
    var username: String
    var title: String
}

struct StateC {
    var username: String
    var value: Int
    var title: String
}

// Pullback for reducerC
reducerA.pullback(
    state: .box { (state: Ref<StateC>) -> Ref<StateA> in
        Ref(
            get: { StateA(username: state.username, value: state.value) }
            set: { substate in
                state.username = substate.username
                state.value = substate.value
            }
        )
    }, ...
)

// similar pullback for reducerB

It's just a draft of the idea, but I think it can be improved and useful

For now as far as I understand there is only one way to syncronize substates is to merge them in a higher reducer like

Reducer<StateC...> { ...
    case .actionA(.changeUsername(to: let username):
        state.stateB.username = username
    
    case .actionB(.changeUsername(to: let username):
        state.stateA.username = username
}

And it becomes more painful if you have collections of substate...

You can also use computed properties for your substates, so you don't have to synchronise them in parent's reducer.

In your case, StateA is set by a reducer on Store<StateA, ActionA> pulled-back in StateC's feature. If you're using computed properties, the values in StateC are set automatically, and are bouncing back to StateB.

It depends on the scenarii of course, but I think that the gist of it. There are case studies showing how to share states in TCA's repository.

I'm also exposing a small helper to achieve this on GitHub. Not that it is useful in your case, but I'm showing how I construct a substate using computed properties. There is a bit of code there which doesn't use my proposal helper, and you can directly backport-it to StateC & co. Since StateC properties can store all the information for StateA and StateB, you don't need the private storage I'm using and directly write:

struct StateC {
  var username: String
  var value: Int
  var title: String

  var stateA: StateA {
    get { StateA(username: self.username, value: self.value) }
    set { 
      self.username = newValue.username
      self.value = newValue.value
    }
  }
  // Same for StateB...
}

You only need to pullback the reducers, and that's it if there is no additional logic to implement!

1 Like

Computed properties for state can be really useful if your substate is entirely derived by parts of the parent state, just remember this doesn’t work where your substate uses a mix of parent state and its own internal state, because that state will be lost whenever you change the substate as it will only persist whatever it sets on the parent.

This post describes how that can be solved, @lukeredpath.