Thoughts on "Action Boundaries" to keep Actions organized and their intent explicit #1440
Replies: 8 comments 35 replies
-
I do tend to use the "delegate" pattern for this. One nice thing when you group your actions is that you can ignore certains groups while switching but handle all actions in another group. You are then notified when there is a new action in the handled group. ex: switch action {
case .delegate(. userLoggedIn): // Do something
case .internal, .internalFromAnotherFeature: return .none
} Here I'm notified whenever there is a new action that the parent /delegate should handle but not when adding a private one. Thought you then need more types, scoping / pullback ... which can be turned into an operator. TBH it really differs from feature to feature. I see TCA as composability oriented and a lot of time there is not much gain to hide those actions. One last thing, splitting your actions force you to clone some actions that would be one if flattened. Like starting a refresh task from a timer and from the UI.. |
Beta Was this translation helpful? Give feedback.
-
The more comprehensive grouping looked like this: public protocol TCAFeatureAction {
associatedtype ViewAction
associatedtype DelegateAction
associatedtype InternalAction
static func view(_: ViewAction) -> Self
static func delegate(_: DelegateAction) -> Self
static func internal(_: InternalAction) -> Self
}
public enum MyFeatureAction: TCAFeatureAction {
enum ViewAction: Equatable {
case didAppear
case toggle(Todo)
case dismissError
}
enum InternalAction: Equatable {
case listResult(Result<[Todo], TodoError>)
case toggleResult(Result<Todo, TodoError>)
}
enum DelegateAction: Equatable {
case ignored
}
case view(ViewAction)
case internal(InternalAction)
case delegate(DelegateAction)
} |
Beta Was this translation helpful? Give feedback.
-
@mbrandonw @stephencelis I see that isowords is actually utilizing multiple actions enums like what is described in this discussion: https://github.com/pointfreeco/isowords/blob/main/Sources/OnboardingFeature/OnboardingView.swift#L153. Would I be better off going all-in on adopting the ideas mentioned in this discussion or is there something you both are cooking up that deviates a bit from this approach? I'm reluctant to write a bunch of code that will eventually need to be refactored in a few months, but then again that could just come with the territory, so to speak ;) |
Beta Was this translation helpful? Give feedback.
-
@krzysztofzablocki by the way, I've been a huge fan of your work (e.g. LifetimeTracker) way before I knew how much of an influence you are in the Swift/iOS community :) nice to see you heavily involved with TCA too :D So reading the very end of your article TCA Action Boundaries, am I correct to say that you now prefer to make ViewAction calls within a View/ViewController and that InternalActions should explicitly be called within the reducer? If so, does that mean the cleaner scope is worth the potential performance cost of sending multiple actions within a reducer where one action would have sufficed? The reason I ask is because in the Performance article, I learned that sending actions is a bit more costly than just calling a simple function. To use a concrete example, let's say I have a MusicLibrary reducer that is supposed to load the music library into state with a
With this new approach, I would be executing
Would this be new approach be frowned down upon because we are now sending two actions where it would've just taken one? Perhaps what I should be taking away from the Performance article is not that sending multiple actions is always discouraged, but that it is costly when an action is being treated as a function executing shared logic throughout the reducer. Sorry if this is me being overly analytical too; I have a tendency to not see the forest for the trees ;) |
Beta Was this translation helpful? Give feedback.
-
Related to this, I'm looking for some clarification about why Is this simply a case of outdated code or is there a different purpose to this I might've missed? |
Beta Was this translation helpful? Give feedback.
-
@krzysztofzablocki @IanKeen I'm new to TCA, so this may be a naive suggestion, but how do you feel about using the name |
Beta Was this translation helpful? Give feedback.
-
I like the approach of separating the actions! But I got bogged down in the scoping: public enum Action: TCAFeatureAction {
case view(ViewAction)
case `internal`(InternalAction)
case delegate(DelegateAction)
public enum ViewAction: Equatable {
case showLogin(ATLoginFeature.Action)
public enum Alert {
case logout
case retry
}
}
public enum InternalAction: Equatable {
case logout
case handleError(EquatableError)
}
public enum DelegateAction: Equatable {
}
}
public var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
}
// Error below: Static method 'buildExpression' requires the types 'ATAppFeature.Action' and 'ATAppFeature.Action.ViewAction?' be equivalent
Scope(state: \.login, action: /Action.ViewAction.showLogin) {
ATLoginFeature()
}
} and similar in the view: NavigationStack {
ATLoginView(
store: store.scope(
state: \.login,
// Error below: Cannot convert value of type '(ATLoginFeature.Action) -> ATAppFeature.Action.ViewAction' to expected argument type '(ATLoginFeature.Action) -> ATAppFeature.Action'
action: ATAppFeature.Action.ViewAction.showLogin
)
)
} I used the extension(s) for scoping internal and view actions: extension Store where Action: TCAFeatureAction {
func scope<ChildState, ChildAction>(
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: CasePath<Action.InternalAction, ChildAction>
) -> Store<ChildState, ChildAction> {
scope(
state: toChildState,
action: { .internal(fromChildAction.embed($0)) }
)
}
}
extension Store where Action: TCAFeatureAction {
func scope<ChildState, ChildAction>(
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: CasePath<Action.ViewAction, ChildAction>
) -> Store<ChildState, ChildAction> {
scope(
state: toChildState,
action: { .view(fromChildAction.embed($0)) }
)
}
} What am I missing? Any help would be appreciated! |
Beta Was this translation helpful? Give feedback.
-
New to TCA, but it quickly noticed this issue. Before seeing this thread, here's what I came up with as a workaround: enum FeatureAction<ViewAction, SideEffect> {
case viewAction(ViewAction)
case sideEffect(SideEffect)
}
protocol Feature: Reducer where Action == FeatureAction<ViewAction, SideEffect> {
associatedtype ViewAction
associatedtype SideEffect
func reduce(into state: inout State, viewAction: ViewAction) -> Effect<SideEffect>
func reduce(into state: inout State, sideEffect: SideEffect) -> Effect<SideEffect>
}
extension Feature {
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .viewAction(let viewAction):
reduce(into: &state, viewAction: viewAction).map { .sideEffect($0) }
case .sideEffect(let sideEffect):
reduce(into: &state, sideEffect: sideEffect).map { .sideEffect($0) }
}
}
}
} |
Beta Was this translation helpful? Give feedback.
-
I recently read this article by Krzysztof Zabłocki, an avid TCA user, who raises some interesting points about observing and handling actions of child reducers in parent reducers. Here's the article: https://www.merowing.info/boundries-in-tca/
Some takeaways:
It looks something like this:
Later he mentions another user (@IanKeen) who takes it a step further and scopes the actions into delegate, view, and internal action types.
I think this makes a lot of sense. I've only recently started integrating features into bigger stores and I can already see how it is becoming a bit tricky to know which child actions I should be looking at from parent reducers.
What does everyone think? Does this all seem like a good idea?
Beta Was this translation helpful? Give feedback.
All reactions