-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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
base: main
Are you sure you want to change the base?
Conversation
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 /* ... */ } ```
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 |
I'd definitely like to hear how others have used this kind of functionality, since I'm having trouble seeing how I can use |
We went down the path of starting with I agree that 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 😄 |
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 ReducerReader { state, _ in
withDependencies {
$0.currentUser = state.currentUser
} operation: {
Feature()
}
} This can be done with |
@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 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 { stateProxy, action in
if stateProxy[\.someDependency].isActive { … }
} It also raises the question to define a |
@tgrapperon Can you access the dependencies using |
@iampatbrown If this is not top-level in Scope(…) {
ReducerReader { state, action in
…
}
}.dependency(\.someDependency, …) But I agree, you can probably tap on |
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 |
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 Luckily I think we could make |
I know that the name is already been used for the Since the name is already taken by the |
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?
The
|
Again, IMO it doesn't has anything to do with reading as the regular
Although I vouch for everything else you've mirrored from SwiftUI syntax like |
Nevertheless, I think these are more similarities than differences:
If we didn't have access to |
Being the one who coined the |
This might be a bit out there, but why not one of these:
Or even simpler:
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)
}
}
} |
My first impression of these is that they're a little more vague and general than
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 |
@oronbz While we're not entirely opposed to the idea, there do seem to be some roadblocks. Using a result builder type (like ReducerBuilder { (state, action) -> SomeReducer in
…
}
-@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
|
Doesn't that expectation depend on knowledge/use of And as far as SwiftUI's readers go, they don't expose the raw
I kinda think of the |
|
@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 I'm currently experimenting with a ReducerReader { proxy in
proxy.run {
print("State before: ", proxy.state)
}
Animations()
proxy.run {
print("State after: ", proxy.state)
}
} (I've pushed it on my Going back to the 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? |
It gives us a |
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. |
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 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 ^^ |
@alexito4 Cool! I think One alternative to |
Thanks for the reply @stephencelis !
That's totally fair 👍🏻 thanks for confirming it ^^
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 :)
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... ❤️ 😜 |
@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 |
Thanks for the inspiration, with the help of how 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
}
}) |
@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 |
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 }
}
} |
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 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()
}
}
} |
This looks really great and I would definitely love to see it in the library. My use case would be to reuse a reducer to be able to run different actions.
And then inject an appropriate action like this
It needs a piece of information from the parent state so this is a perfect tool for my use case. I've just tried it and it works like a charm. Are there any plans to include it in the library? |
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 themStateReader
andActionReader
, etc., which nicely evokesScrollViewReader
andGeometryReader
, 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 itReducerReader
.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:A couple example uses:
Exhaustively handling enum state:
Filtering:
Feedback, please!
We'd love any feedback the community may have, especially from those that have used this kind of reducer in their applications.