swift-composable-architecture
swift-composable-architecture copied to clipboard
Add `ReducerReader` for building reducers out of state and action
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.
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!
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!
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.
As much as I like the more scoped
StateReader
andActionReader
, 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:
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)
}
}
@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.
@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 :)
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 Can you access the dependencies using @Dependency
on the reducer? Eg. self.someDependency.isActive
@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.
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)
}
}
}
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 ;)
@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.
[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.
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 read
ing than any regular Reducer
.
I know that the name is already been used for the
@ReducerBuilder
@resultBuilder
butReducerBuilder
instead ofReducerReader
just makes much more sense for me and you even documented theReducerReader
as such:/// A reducer that builds a reducer from the current state and action.
It actually took me a while to understand what thisReducerReader
is used for until I finally understood that it is aBuilder
.
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, butReader
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).
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 withScrollViewReader
andGeometryReader
:
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.
[…]
ScrollViewReader
andGeometryReader
have a totally different purpose thanReducerReader
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 theSwiftUI.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 ordinaryReduce
reducer and its child'sreduce(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.
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.
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)
}
}
}
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.
Although I vouch for everything else you've mirrored from SwiftUI syntax like
body
,@dependency (environment)
and others, theScrollViewReader
andGeometryReader
have a totally different purpose thanReducerReader
as they give you a specific lens as to what's going on inside their content which you could not get otherwise, whileReducerReader
just gives you the same information you'll get with normalReducer
but itsreduce
function is returning@ReducerBuilder
instead ofEffect
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:
-
I'm not sure the builder and non-builder initializers can be disambiguated easily by Swift.
-
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 forswift(<5.8)
or9
.
We would naively expect
ReducerReader { reducer in … }
, but a rawReducer
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 haveReducerReader { 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.
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.
@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.
@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 ReducerReader
s 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
…
}
}
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?
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.
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)
}
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.