swift-composable-architecture icon indicating copy to clipboard operation
swift-composable-architecture copied to clipboard

Add `ReducerReader` for building reducers out of state and action

Open stephencelis opened this issue 1 year ago • 40 comments

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.

stephencelis avatar Mar 08 '23 19:03 stephencelis

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!

pyrtsa avatar Mar 08 '23 19:03 pyrtsa

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!

tgrapperon avatar Mar 08 '23 19:03 tgrapperon

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.

acosmicflamingo avatar Mar 08 '23 19:03 acosmicflamingo

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 :smile:

stephencelis avatar Mar 08 '23 20:03 stephencelis

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)
  }
}

stephencelis avatar Mar 08 '23 20:03 stephencelis

@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.

pyrtsa avatar Mar 08 '23 20:03 pyrtsa

@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 :)

acosmicflamingo avatar Mar 08 '23 20:03 acosmicflamingo

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]

tgrapperon avatar Mar 08 '23 20:03 tgrapperon

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

iampatbrown avatar Mar 08 '23 20:03 iampatbrown

@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.

tgrapperon avatar Mar 08 '23 20:03 tgrapperon

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)
    }
  }
}

lukeredpath avatar Mar 08 '23 20:03 lukeredpath

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 ;)

acosmicflamingo avatar Mar 08 '23 20:03 acosmicflamingo

@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.

tgrapperon avatar Mar 08 '23 20:03 tgrapperon

[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.

stephencelis avatar Mar 08 '23 21:03 stephencelis

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.

oronbz avatar Mar 08 '23 21:03 oronbz

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).

stephencelis avatar Mar 09 '23 01:03 stephencelis

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.

oronbz avatar Mar 09 '23 05:03 oronbz

[…] 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.

pyrtsa avatar Mar 09 '23 07:03 pyrtsa

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.

tgrapperon avatar Mar 09 '23 14:03 tgrapperon

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)
    }
  }
}

davdroman avatar Mar 09 '23 14:03 davdroman

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 avatar Mar 09 '23 16:03 stephencelis

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 avatar Mar 09 '23 18:03 stephencelis

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.

stephencelis avatar Mar 09 '23 18:03 stephencelis

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.

jshier avatar Mar 09 '23 18:03 jshier

@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.

oronbz avatar Mar 09 '23 19:03 oronbz

@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
    …
  }
}

tgrapperon avatar Mar 09 '23 19:03 tgrapperon

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?

kugel3 avatar Mar 11 '23 16:03 kugel3

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.

pyrtsa avatar Mar 11 '23 17:03 pyrtsa

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)
}

tgrapperon avatar Mar 11 '23 18:03 tgrapperon

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.

kugel3 avatar Mar 12 '23 08:03 kugel3