Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ReducerReader for building reducers out of state and action #1969

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

stephencelis
Copy link
Member

Let's consider adding a reducer that builds itself from the current state and action. We've experimented with the concept before and called it _Observe, but decided to wait a bit before shipping such a thing. More and more community members have built their own versions of _Observe, though, calling them StateReader and ActionReader, etc., which nicely evokes ScrollViewReader and GeometryReader, and we've also found more and more uses, so let's consider shipping this tool as a first-class citizen, and let's consider calling it ReducerReader.

We think a single ReducerReader entity is the way to go vs. three reducers, one for reading state and action (ReducerReader), and then one for just reading state (StateReader) and one for just reading actions (ActionReader), since you can simply ignore the value you don't need to read:

// Instead of:
StateReader { state in /* ... */ }
// Do:
ReducerReader { state, _ in /* ... */ }

// Instead of:
ActionReader { action in /* ... */ }
// Do:
ReducerReader { _, action in /* ... */ }

A couple example uses:

Exhaustively handling enum state:

var body: some ReducerProtocol<State, Action> {
  ReducerReader { state, _ in
    switch state {  // Compile-time failure if new cases are added
    case .loggedIn:
      Scope(state: /AppFeature.loggedIn, action: /AppFeature.loggedIn) {
        LoggedInFeature()
      }
    case .loggedOut:
      Scope(state: /AppFeature.loggedOut, action: /AppFeature.loggedOut) {
        LoggedOutFeature()
      }
    }
  }
}

Filtering:

var body: some ReducerProtocol<State, Action> {
  ReducerReader { state, action in
    if state.isAdmin && action.isAdmin {
      AdminFeature()
    }
  }
}

Feedback, please!

We'd love any feedback the community may have, especially from those that have used this kind of reducer in their applications.

We've experimented with this before and called it `_Observe`, but
weren't using it. More and more community members have built their own
versions of `_Observe` and called them `StateReader` and `ActionReader`,
etc., which evokes `ScrollViewReader` and `GeometryReader` nicely, so
let's consider shipping this tool as a first-class citizen.

A couple example uses:

```swift
var body: some ReducerProtocol<State, Action> {
  ReducerReader { state, _ in
    switch state {  // Compile-time failure if new cases are added
    case .loggedIn:
      Scope(state: /AppFeature.loggedIn, action: /AppFeature.loggedIn) {
        LoggedInFeature()
      }
    case .loggedOut:
      Scope(state: /AppFeature.loggedOut, action: /AppFeature.loggedOut) {
        LoggedOutFeature()
      }
    }
  }
}
```

```swift
var body: some ReducerProtocol<State, Action> {
  ReducerReader { state, action in
    if state.isAdmin && action.isAdmin {
      AdminFeature()
    }
  }
}
```

We'd love any feedback the community may have, especially from those
that have used this kind of reducer in their applications.

We think a single `ReducerReader` entity is the way to go vs. three
reducers, one for reading state _and_ action (`ReducerReader`), and then
one for just reading state (`StateReader`) and one for just reading
actions (`ActionReader`), since you can simply ignore the value you
don't need to read:

```swift
// Instead of:
StateReader { state in /* ... */ }
// Do:
ReducerReader { state, _ in /* ... */ }

// Instead of:
ActionReader { action in /* ... */ }
// Do:
ReducerReader { _, action in /* ... */ }
```
@pyrtsa
Copy link
Contributor

pyrtsa commented Mar 8, 2023

AFAICT this is a key component in reducers which inject a projection of their state or action as dependencies for their children, so a much needed building block. Yes please!

@tgrapperon
Copy link
Contributor

As much as I like the more scoped StateReader and ActionReader, I admit that having three reducers with one of them being pluripotent wouldn't make a lot of sense, so it looks good to me!

@acosmicflamingo
Copy link
Contributor

acosmicflamingo commented Mar 8, 2023

I'd definitely like to hear how others have used this kind of functionality, since I'm having trouble seeing how I can use ReducerReader in my own codebase right now.

@stephencelis
Copy link
Member Author

As much as I like the more scoped StateReader and ActionReader, I admit that having three reducers with one of them being pluripotent wouldn't make a lot of sense, so it looks good to me!

We went down the path of starting with StateReader since we didn't think ActionReader was as likely to be used, but a quick look at some reducers in isowords made it clear that operators like Reducer.filter are really action readers in disguise.

I agree that StateReader and ActionReader read nicely, but maybe don't carry their weight to ship just yet.

Helper functions shouldn't be too bad to define in your application if you want to experiment:

func StateReader<State, Action, Reader: ReducerProtocol<State, Action>>(
  @ReducerBuilder<State, Action> _ reader: @escaping (State) -> Reader
) -> some ReducerProtocol<State, Action> {
  ReducerReader { state, _ in reader(state) }
}

func ActionReader<State, Action, Reader: ReducerProtocol<State, Action>>(
  @ReducerBuilder<State, Action> _ reader: @escaping (Action) -> Reader
) -> some ReducerProtocol<State, Action> {
  ReducerReader { _, action in reader(action) }
}

These helpers are almost as bulky as dedicated conformances, though 😄

@stephencelis
Copy link
Member Author

I'd definitely like to hear how others have used this kind of functionality, since I'm having trouble seeing how I can use ReducerReader in my own codebase right now.

In addition to the 2 quick examples I give in the PR body, you could also use this do inject dependencies based on the current state:

var body: some ReducerProtocol<State, Action> {
  ReducerReader { state, _ in
    Feature()
      .dependency(\.apiClient, state.isOnboarding ? .onboarding : .liveValue)
  }
}

@pyrtsa
Copy link
Contributor

pyrtsa commented Mar 8, 2023

@acosmicflamingo One example could be letting child features know the currently logged in user via @Dependency(\.currentUser) var user: User?, something like:

ReducerReader { state, _ in
  withDependencies {
    $0.currentUser = state.currentUser
  } operation: {
    Feature()
  }
}

This can be done with func reduce(into:action:) or Reduce currently, but doesn't play as nicely with reducer builders.

@acosmicflamingo
Copy link
Contributor

@stephencelis @pyrtsa oh I see! That is REALLY interesting. I haven't found instances where this is needed because my Reducers comprise very straightforward Scope functions and the classic Reduce<State, Action>.

This PR however would give developers even more control as to what child reducers should be generated based on the state of parent reducer. While this kind of behavior already exists with optional child states and ifLet, we don't have it for non-optional states. It will probably will make reducers like this much easier to read: https://github.com/pointfreeco/isowords/blob/main/Sources/AppFeature/AppView.swift#L87. Or am I still missing something :)

@tgrapperon
Copy link
Contributor

tgrapperon commented Mar 8, 2023

If this is a ReducerReader, it can make sense to want to read its dependencies in case where .transformDependencies would be too late to decide. Here is an example of a StateProxy where we can access the dependencies using a subscript, in the same fashion we can resolve anchors using a subscript on GeometryProxy.

ReducerReader { stateProxy, action in
  if stateProxy[\.someDependency].isActive {  }
}

It also raises the question to define a ReducerProxy with a state, an action and a dependencies properties, which would make this reducer work with a one-argument closure that will be less confusing than its current form with is close to Reduce's one, and avoid using a _ placeholder in the majority of cases where one only need the state or the action.
[Edit: Here is a branch with a ReducerProxy]

@iampatbrown
Copy link
Contributor

@tgrapperon Can you access the dependencies using @Dependency on the reducer? Eg. self.someDependency.isActive

@tgrapperon
Copy link
Contributor

tgrapperon commented Mar 8, 2023

@iampatbrown If this is not top-level in body, there are cases where they can be different, for example:

Scope() {
  ReducerReader { state, action in
    
  }
}.dependency(\.someDependency, )

But I agree, you can probably tap on self in the majority of cases.

@lukeredpath
Copy link
Contributor

I'd definitely like to hear how others have used this kind of functionality, since I'm having trouble seeing how I can use ReducerReader in my own codebase right now.

One good use case is sharing some state across all of your features as a dependency. For example, imagine once logged in you have some kind of persistent session data that contains some information about the logged in user - this might include profile data, or their user ID etc. which you need to use throughout your features (you might need the user ID to include in API requests).

Rather than passing this down from feature to feature, you can take a similar approach to SwiftUI (where you could use the environment to pass data down to deeply nested views) and use a dependency key to pass that data around. In order to set this on all of your features you need to set the dependency at the highest point within your reducer tree that has access to the data.

For example:

struct LoggedInReducer: ReducerProtocol {
  var body: some ReducerProtocol<State, Action> {
    ReducerReader { state, _ in 
      CombineReducers { // using this like `Group` so we only need to set the dependency once
        ChildFeatureOne()
        ChildFeatureTwo()
      }
      .dependency(\.sessionData, state.sessionData)
    }
  }
}

@acosmicflamingo
Copy link
Contributor

I'd definitely like to hear how others have used this kind of functionality, since I'm having trouble seeing how I can use ReducerReader in my own codebase right now.

One good use case is sharing some state across all of your features as a dependency. For example, imagine once logged in you have some kind of persistent session data that contains some information about the logged in user - this might include profile data, or their user ID etc. which you need to use throughout your features (you might need the user ID to include in API requests).

Rather than passing this down from feature to feature, you can take a similar approach to SwiftUI (where you could use the environment to pass data down to deeply nested views) and use a dependency key to pass that data around. In order to set this on all of your features you need to set the dependency at the highest point within your reducer tree that has access to the data.

For example:

struct LoggedInReducer: ReducerProtocol {
  var body: some ReducerProtocol<State, Action> {
    ReducerReader { state, _ in 
      CombineReducers { // using this like `Group` so we only need to set the dependency once
        ChildFeatureOne()
        ChildFeatureTwo()
      }
      .dependency(\.sessionData, state.sessionData)
    }
  }
}

WOW that is a neat example for sure. Thanks! Can't wait to eventually use it when my app gets a little more complex ;)

@tgrapperon
Copy link
Contributor

@iampatbrown It's not clear if the potential ReducerProxy (or StateProxy) should escape dependencies on creation, or resolve locally. Both are possible simultaneously BTW and we could use this to compare dependencies across reducers' boundaries, which would be something new I guess.

@stephencelis
Copy link
Member Author

[Edit: Here is a branch with a ReducerProxy]

Interesting thoughts, @tgrapperon! I think I'd need more concrete examples to wrap my head around it, so such a change might take another period of time to consider (like the period of time between _Observe and this PR).

Luckily I think we could make ReducerProxy additive if it gets motivated in the future, since a closure with a single parameter will resolve to it, and a closure with two can resolve to the existing interface.

@oronbz
Copy link
Contributor

oronbz commented Mar 8, 2023

I know that the name is already been used for the @ReducerBuilder @resultBuilder but ReducerBuilder instead of ReducerReader just makes much more sense for me and you even documented the ReducerReader as such:
/// A reducer that builds a reducer from the current state and action.
It actually took me a while to understand what this ReducerReader is used for until I finally understood that it is a Builder.

Since the name is already taken by the @resultBuilder a synonym could take place instead, I just can't think of a good alternative, but Reader definitely sound like it has a different purpose. IMO, it doesn't have any more reading than any regular Reducer.

@stephencelis
Copy link
Member Author

I know that the name is already been used for the @ReducerBuilder @resultBuilder but ReducerBuilder instead of ReducerReader just makes much more sense for me and you even documented the ReducerReader as such: /// A reducer that builds a reducer from the current state and action. It actually took me a while to understand what this ReducerReader is used for until I finally understood that it is a Builder.

That doc could easily be changed to:

/// A reducer that builds a reducer by reading the current state and action.

Does that make the "reading" aspect more compelling?

Since the name is already taken by the @resultBuilder a synonym could take place instead, I just can't think of a good alternative, but Reader definitely sound like it has a different purpose.

The Reader naming fits in well with ScrollViewReader and GeometryReader:

  • ScrollViewReader: Read information about the current scroll view (e.g., its scroll position).
  • GeometryReader: Read information about the current geometry (e.g., its dimensions).
  • ReducerReader: Read information about the current reducer (e.g., its current state and action).

@oronbz
Copy link
Contributor

oronbz commented Mar 9, 2023

Does that make the "reading" aspect more compelling?

Again, IMO it doesn't has anything to do with reading as the regular Reducer.

The Reader naming fits in well with ScrollViewReader and GeometryReader:

  • ScrollViewReader: Read information about the current scroll view (e.g., its scroll position).
  • GeometryReader: Read information about the current geometry (e.g., its dimensions).
  • ReducerReader: Read information about the current reducer (e.g., its current state and action).

Although I vouch for everything else you've mirrored from SwiftUI syntax like body, @dependency (environment) and others, the ScrollViewReader and GeometryReader have a totally different purpose than ReducerReader as they give you a specific lens as to what's going on inside their content which you could not get otherwise, while ReducerReader just gives you the same information you'll get with normal Reducer but its reduce function is returning @ReducerBuilder instead of Effect that's all.

@pyrtsa
Copy link
Contributor

pyrtsa commented Mar 9, 2023

[…] ScrollViewReader and GeometryReader have a totally different purpose than ReducerReader as they give you a specific lens as to what's going on inside their content which you could not get otherwise […]

Nevertheless, I think these are more similarities than differences:

  • ScrollViewReader provides to its body a proxy handle to the surrounding scroll view component (mind you: not the SwiftUI.ScrollView, which is just a declarative description of what's seen in the UI, but its actual visible counterpart in the view graph, such as—but not necessarily—UIScrollView).
  • GeometryReader provides to its body access to the geometry of its surrounding container view for layout computation.
  • Yes, you're correct that you can express all of ReducerReader by making use of an ordinary Reduce reducer and its child's reduce(into:action:) function, but I'm guessing you would have equivalent abilities in SwiftUI if it made its view graph API public (which it's very unlikely to do of course).

If we didn't have access to func reduce and had to express everything in terms of var body, then ReducerReader would be the only way to read the current state or action from the enclosing reducer before giving control to scoped child reducers.

@tgrapperon
Copy link
Contributor

Being the one who coined the Reader name a few months ago, I agree with everything @pyrtsa says. But I also agree that it made more sense in StateReader { state in … } and ActionReader { action in … }. We would naively expect ReducerReader { reducer in … }, but a raw Reducer wouldn't make a lot of sense here. Thats one of the reasons I'm currently experimenting with a ReducerProxy. In this case, we'd have ReducerReader { proxy in … } and the parallel with SwiftUI would be even more striking.

@davdroman
Copy link

davdroman commented Mar 9, 2023

This might be a bit out there, but why not one of these:

  • ReducerFrom { state, action in ...
  • ReducerWith { state, action in ...

Or even simpler:

  • From { state, action in ...
  • With { state, action in ... <- personally fond of this one

Some examples from previous comments:

var body: some ReducerProtocol<State, Action> {
  With { state, _ in
    switch state {  // Compile-time failure if new cases are added
    case .loggedIn:
      Scope(state: /AppFeature.loggedIn, action: /AppFeature.loggedIn) {
        LoggedInFeature()
      }
    case .loggedOut:
      Scope(state: /AppFeature.loggedOut, action: /AppFeature.loggedOut) {
        LoggedOutFeature()
      }
    }
  }
}
var body: some ReducerProtocol<State, Action> {
  With { state, action in
    if state.isAdmin && action.isAdmin {
      AdminFeature()
    }
  }
}
var body: some ReducerProtocol<State, Action> {
  With { state, _ in
    Feature()
      .dependency(\.apiClient, state.isOnboarding ? .onboarding : .liveValue)
  }
}
struct LoggedInReducer: ReducerProtocol {
  var body: some ReducerProtocol<State, Action> {
    With { state, _ in 
      CombineReducers { // using this like `Group` so we only need to set the dependency once
        ChildFeatureOne()
        ChildFeatureTwo()
      }
      .dependency(\.sessionData, state.sessionData)
    }
  }
}

@stephencelis
Copy link
Member Author

stephencelis commented Mar 9, 2023

This might be a bit out there, but why not one of these:

  • ReducerFrom { state, action in ...
  • ReducerWith { state, action in ...

My first impression of these is that they're a little more vague and general than ReducerReader. It's not as clear that ReducerFrom or ReducerWith distinguish themselves from Reduce (but with a builder context).

Or even simpler:

  • From { state, action in ...
  • With { state, action in ... <- personally fond of this one

I think these names are unfortunately a bit too general beyond even the library, and likely to collide with other types. That could force folks to qualify as ComposableArchitecture.From and ComposableArchitecture.With, which don't read as nicely. If Swift ever gets abbreviated type resolution for types defined inside builders (something that's been proposed on the forums) we could revisit, but for now I think we need something a bit more unique.

@stephencelis
Copy link
Member Author

stephencelis commented Mar 9, 2023

Although I vouch for everything else you've mirrored from SwiftUI syntax like body, @dependency (environment) and others, the ScrollViewReader and GeometryReader have a totally different purpose than ReducerReader as they give you a specific lens as to what's going on inside their content which you could not get otherwise, while ReducerReader just gives you the same information you'll get with normal Reducer but its reduce function is returning @ReducerBuilder instead of Effect that's all.

@oronbz While we're not entirely opposed to the idea, there do seem to be some roadblocks. Using a result builder type (like SwiftUI.ViewBuilder) as anything more than the @resultBuilder itself doesn't seem to have a precedent in the Apple ecosystem that I'm aware of, but in our case it also doesn't seem possible:

ReducerBuilder { (state, action) -> SomeReducer in
  
}

ReducerBuilder is generic over State and Action, but whatever type is used to build these reducers must also be generic over the SomeReducer returned by the builder:

-@resultBuilder enum ReducerBuilder<State, Action> {
+@resultBuilder struct ReducerBuilder<State, Action, Content: ReducerProtocol>: ReducerProtocol
+where Content.State == State, Content.Action == Action {

This generic would break the existing builder. So I think we definitely need a unique type and name.

Reusing Reduce might be a nice alternative, as we could technically introduce another generic there (and even default it to a ClosureReducer or something when using the non-builder initializer), but:

  1. I'm not sure the builder and non-builder initializers can be disambiguated easily by Swift.

  2. Even if they can be disambiguated, there's a Swift diagnostics bug that currently pushes us to encourage folks to specify the generics of Reduce when working in builders with multiple lines in order to restore autocomplete:

    -Reduce { state, action in
    +Reduce<State, Action> { state, action in

    We'd need to start encouraging the following, instead:

    Reduce<State, Action, _> { state, action in

    This is not only a breaking change, but kinda messy.

    Now, whenever the Swift bug is fixed we could revisit using Reduce for both styles, but we'd still need an alternative for swift(<5.8) or 9.

@stephencelis
Copy link
Member Author

We would naively expect ReducerReader { reducer in … }, but a raw Reducer wouldn't make a lot of sense here.

Doesn't that expectation depend on knowledge/use of StateReader and ActionReader? Since we've never employed those in the library I think we're hopefully in the clear there.

And as far as SwiftUI's readers go, they don't expose the raw ScrollView, etc., and instead surface proxies, as you mention:

Thats one of the reasons I'm currently experimenting with a ReducerProxy. In this case, we'd have ReducerReader { proxy in … } and the parallel with SwiftUI would be even more striking.

I kinda think of the state and value passed as a proxy to the current state and value that can more conveniently be destructured. And I'm definitely not opposed to ReducerProxy, especially if there's more than state and action to motivate. But even then it seems the current destructuring shorthand of state, action would still be nice to support. I think we could additively consider adding the proxy later if we can't motivate it yet right now, and instead we could start with the API surface area of this PR.

@jshier
Copy link
Contributor

jshier commented Mar 9, 2023

ActionStateReducer seems like an obvious, if inelegant, name for exactly what's being reduced here. It also echoes the original names of the separate reducers. Technically it could be StateActionReducer to match the parameter order, but that feels wrong, likely due to some English-ism I can't name. There's also a slight risk of users confusing ActionState with the other types of state in the library, but seems easily clarified in the docs and at point of use.

@oronbz
Copy link
Contributor

oronbz commented Mar 9, 2023

@stephencelis I totally agree with the name clashes as I mentioned them in my original comment. And hopefully Swift will evolve enough someday to allow more stability in its disambiguity (not a word). Until then, I'm fine with whatever way this is going, as it really a useful tool in moderate+ complexity apps.

@tgrapperon
Copy link
Contributor

tgrapperon commented Mar 9, 2023

@stephencelis. I agree with the fact that ReducerReader { reducer in … } is probably related to the knowledge of State/Action reader. The point I wanted to make is that StateReader { state in … } function is immediately clear from its sole signature. ReducerReader { state, action in … } feels less natural and the discussion above shows that the expectations there are not univocal. This is originally what motivated me to bundle them into a "proxy" type that wouldn't take a stance. In SwiftUI, GeometryProxy is something very versatile that can help many different situations, so I'm indeed trying to see how we can expand this hypothetical ReducerProxy with additional informations/utilities.

I'm currently experimenting with a ReducerProxy that exposes .state, .action and .dependencies, as well as a .run method where we can potentially produce an Effect using the state and action, at any point in the ReducerReader stack. For example:

ReducerReader { proxy in
  proxy.run {
    print("State before: ", proxy.state)
  }
  Animations()
  proxy.run {
    print("State after: ", proxy.state)
  }
}

(I've pushed it on my reader-reducer-proxy branch BTW).
There is some TaskLocal magic to make it work and it probably has a few edge-cases implemented like it is. I'm just trying to see if it offers novel ways to write reducers on the spot, and we can instead probably spawn new ReducerReaders in the closure to get the local state and action and achieve the same behavior.

Going back to the ReducerReader naming question and forgetting ReducerProxy for a moment, we can also imagine providing only StateReader and ActionReader, and let the user nest them in the less common cases where they need both informations:

StateReader { state in
  ActionReader { action in
    
  }
}

@kugel3
Copy link

kugel3 commented Mar 11, 2023

I've read this a couple of times, but I still can't figure out how this differs from a normal reducer, is there a short explanation of that?

@pyrtsa
Copy link
Contributor

pyrtsa commented Mar 11, 2023

I've read this a couple of times, but I still can't figure out how this differs from a normal reducer, is there a short explanation of that?

It gives us a ReducerBuilder with (State, Action) as parameters. Contrast that with an ordinary body of a reducer which takes no parameter; or the reduce(into:action:) function which does take those two arguments but returns the EffectTask already instead of another reducer.

@tgrapperon
Copy link
Contributor

In other words:

Reduce { state, action in
  // You produce an `EffectTask` here (or `Effect` soon)
}
ReducerReader { state, action in
  // You produce a `ReducerProtocol` here (or `Reducer` soon)
}

@kugel3
Copy link

kugel3 commented Mar 12, 2023

Ah alright, so it's if you already have a reducer and you want to reuse it in a way that depends on state or action.

@alexito4
Copy link
Contributor

Hi @stephencelis ,

We've been using a "relay" reducer since the beginning and I'm curious if this ReaderReader could be its replacement (which means less things we have to maintain ^^).

Our use case is to integrate TCA with other parts of the app (typical UIKit navigation coordinators). What we do is in the coordinator, when creating the screen with the TCA store we hook a relay that let's us inspect the actions and call to coordinator methods.

let store = Store(
            initialState: .init(...),
            reducer: FeatureReducer()
                .relay {
                    switch $0 {
                    case .action(.closeTapped):
                        onCloseButtonTapped()
                    case .action(.getStartedTapped):
                        onCloseButtonTapped()
                    default:
                        break
                    }
                }
        )

This is hugely beneficial since it let's us keep the TCA features "pure" int he sense that they don't know they are integrated in a non-tca app. And if tomorrow we want to switch from coordinator to root level TCA the features don't have to change :)

Do you guys think that using this new Reader is a good replacement? On the surface it seems like a good replacement but right now it's not really fitted. Since the closure needs to build a reducer, but the reducer doesn't really depend on the state/action (which I think is why this doesn't really fit our use case) you need to let _ for it to compile, which feels awkward.

            reducer: ReducerReader { _, action in
                switch action {
                case .action(.closeTapped):
                    let _ = onCloseButtonTapped()
                case .action(.getStartedTapped):
                    let _ = onCloseButtonTapped()
                default:
                    let _ = ()
                }
                CinemasWalkthroughScreen()
            }

I'm just curious what you guys think :) is fine if this is a different use case than the one being tackled here ^^

@stephencelis
Copy link
Member Author

@alexito4 Cool! I think relay as you've written is probably great, and I think ReducerReader is not appropriate for the task given the issues you've highlighted. I'll just note that your relay implementation should probably be executing its work inside an Effect and not directly inside the reducer it wraps (though because relay is invoked in the view/controller, this may not be a big issue). I think you've highlighted that there's a gap in the library for bridging actions from TCA back into the broader world, though, so it's something we'll think more about for 1.0.

One alternative to relay to consider, that would get you test coverage on this integration, is if you pushed onCloseButtonTapped into a dependency. This may be more work than necessary, though!

@alexito4
Copy link
Contributor

Thanks for the reply @stephencelis !

@alexito4 Cool! I think relay as you've written is probably great, and I think ReducerReader is not appropriate for the task given the issues you've highlighted.

That's totally fair 👍🏻 thanks for confirming it ^^

I'll just note that your relay implementation should probably be executing its work inside an Effect and not directly inside the reducer it wraps (though because relay is invoked in the view/controller, this may not be a big issue). I think you've highlighted that there's a gap in the library for bridging actions from TCA back into the broader world, though, so it's something we'll think more about for 1.0.

Yes exactly ^^ We are aware that side effects in the reducer are not ideal but we only use this relay outside the TCA, just to integrate with our coordinator, so at that point we didn't mind sacrificing purity for ease of use. We just needed a way to get actions :)

One alternative to relay to consider, that would get you test coverage on this integration, is if you pushed onCloseButtonTapped into a dependency. This may be more work than necessary, though!

We considered the different options and ultimately opted for pretending that the feature was integrated in a full TCA app. So from the feature perspective it just has actions (delegate actions recently) that the parent hooks into. From that domain perspective the parent could be TCA or anything else; allowing us in the future to move features around and maybe get rid of the coordinator system we have (although is nice because it gives a split between features and workarounds possible issues of structs becoming to big for the stack).

I'm aware is not ideal and testing is a bit of a PITA but the tradeoffs are worth it for us.

Now if you guys come up with a better tool in 1.0... ❤️ 😜

@oronbz
Copy link
Contributor

oronbz commented Mar 16, 2023

@alexito4 might share the relay operator? It might be beneficial for us as well (: love the idea

@alexito4
Copy link
Contributor

@alexito4 might share the relay operator? It might be beneficial for us as well (: love the idea

I just remembered I wrote about it here https://alejandromp.com/blog/observe-actions-in-the-composable-architecture/ with link to the repo, although we haven't kept up with updates on that repo. Still you should be able to take the relay method easily 🙏

@oronbz
Copy link
Contributor

oronbz commented Mar 19, 2023

@alexito4 might share the relay operator? It might be beneficial for us as well (: love the idea

I just remembered I wrote about it here https://alejandromp.com/blog/observe-actions-in-the-composable-architecture/ with link to the repo, although we haven't kept up with updates on that repo. Still you should be able to take the relay method easily 🙏

Thanks for the inspiration, with the help of how BindingAction is defined I've succeeded creating even more strictly typed delegate (relay):

public extension Reducer {
    /// Delegate actions sent trough this reducer.
    @ReducerBuilder<State, Action>
    func delegate(
        destination: @escaping (Action.Delegate) async -> Void
    ) -> some ReducerOf<Self> where Action: Delegating {
        self
        Reduce<State, Action> { _, action in
            guard let delegateAction = (/Action.delegate).extract(from: action) else { return .none }
            
            return .fireAndForget {
                await destination(delegateAction)
            }
        }
    }
}

public protocol Delegating {
  associatedtype Delegate

  static func delegate(_ action: Delegate) -> Self
}
    enum Action: Equatable, BindableAction, Delegating {
...
        case delegate(Delegate)
        
        enum Delegate: Equatable {
            case challengeReceived(Challenge)
            case supportedCountryChanged(CountryInformation)
        }
    }

Then, on the coordinator:

       let store = Store(initialState: LoginFeature.State(),
                          reducer: LoginFeature()
            .delegate { @MainActor [weak self] action in
                switch action {
                case .challengeReceived(let challenge):
                    // Coordinator's logic
                case .supportedCountryChanged(let country):
                    // Coordinator's logic
                }
            })

@JimRoepcke
Copy link

@stephencelis I haven't read all the discussion here, but before I do, I just have to give this concept TWO HUGE THUMBS UP! We need this so bad right now, I think it'll help us unify our two root TCA stores, one that handles the logged out situation and one that handles the logged in situation.

We still have issues with @Dependency though, because it's a single static namespace, but we've just had to avoid using @Dependency for dependencies that depend on things like user session access tokens. :(

@fabstu
Copy link

fabstu commented Jun 4, 2023

I love the idea!

I use ReducerReader to pass a nodeID dependency from the parent to all child reducers which allows me vastly more modularity. I want the app to crash when I accidentally forgot to override the nodeID since a default does not make sense. Haven't tried it in tests, but it works in my small example.

Example:

ReducerReader { state, action in
  Item()
    .dependency(\.nodeID, { (state.id })
}

private struct NodeIDDepdendency {}

extension NodeIDDepdendency: DependencyKey {
  
  public static let liveValue: () -> NodeID = {
    fatalError("no nodeID is set")
  }
  
}

extension DependencyValues {
  public var nodeID: () -> NodeID {
    get { self[NodeIDDepdendency.self] }
    set { self[NodeIDDepdendency.self] = newValue }
  }
}

@johankool
Copy link
Contributor

This looks like a useful tool that I didn't know I was missing yet. For the naming, it did remind me very much of flatMap, but I am not 100% sure it maps conceptually.

The examples would then work out to something like this.

Exhaustively handling enum state:

var body: some Reducer<State, Action> {
  EmptyReducer()
  .flatMap { state, _ in
    switch state {  // Compile-time failure if new cases are added
    case .loggedIn:
      Scope(state: /AppFeature.loggedIn, action: /AppFeature.loggedIn) {
        LoggedInFeature()
      }
    case .loggedOut:
      Scope(state: /AppFeature.loggedOut, action: /AppFeature.loggedOut) {
        LoggedOutFeature()
      }
    case .foo:
      EmptyReducer()
    }
  }
}

Filtering:

var body: some ReducerProtocol<State, Action> {
  Reducer { state, action in
    ...
  }
  .flatMap  { state, action in
    if state.isAdmin && action.isAdmin {
      AdminFeature()
    } else {
      EmptyReducer()
    }
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet