TCA Tips: The Power of ViewActions
Learn how ViewAction cleans up reducers in The Composable Architecture by handling view-specific actions.
In today’s post, I want to dive into how ViewAction can simplify sending actions to your reducer in The Composable Architecture (TCA). By using ViewAction, you can clearly separate view-specific actions from the business logic, resulting in cleaner and more maintainable code. Let’s explore how this works!
Why Use ViewActions?
When your view triggers actions that interact with the reducer, like button taps, it’s important to maintain a clear distinction between the view’s responsibilities and the reducer’s internal logic. ViewAction enables you to separate view-specific actions, ensuring that the view can only send the actions relevant to it, while the reducer handles the internal business logic.
By conforming your reducer’s Action enum to the ViewAction protocol, you can encapsulate view-related actions within a nested View enum, keeping them isolated from your reducer’s internal actions.
Example Scenario
Let’s start with a simple reducer for a purchase feature. We have two buttons in the view: one for closing and another for making a purchase.
Here’s how the initial reducer might look:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Reducer
public struct PurchaseFeature {
@ObservableState
public struct State {}
public enum Action: BindableAction {
case binding(BindingAction<State>)
case purchaseButtonTapped
case closeButtonTapped
case purchaseItem
}
public var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .purchaseButtonTapped:
return .send(.purchaseItem)
case .closeButtonTapped:
return .none
case .purchaseItem:
return .none
}
}
}
}
And the corresponding view:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public struct PurchaseView: View {
@Bindable public var store: StoreOf<PurchaseFeature>
public var body: some View {
VStack(spacing: 20) {
Button("Close") {
store.send(.closeButtonTapped)
}
Button("Purchase") {
store.send(.purchaseButtonTapped)
}
}
}
}
The Problem
In this setup, the view can access all actions, including those that should be internal to the reducer, like .purchaseItem. This exposes more than necessary, which can lead to confusion and potential misuse.
The Solution: Separating View and Reducer Actions
To solve this, we can introduce a View enum within the Action enum that will handle view-related actions separately. Here’s how we can implement it:
- Conform the
Actionenum to theViewActionprotocol. - Add a nested
Viewenum insideActionfor view-specific actions. - Use a single case
.viewin theActionenum to capture these view actions.
Updated reducer code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Reducer
public struct PurchaseFeature {
@ObservableState
public struct State {}
public enum Action: BindableAction, ViewAction {
case binding(BindingAction<State>)
case view(View)
case purchaseItem
public enum View {
case purchaseButtonTapped
case closeButtonTapped
}
}
public var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case let .view(viewAction):
switch viewAction {
case .purchaseButtonTapped:
return .send(.purchaseItem)
case .closeButtonTapped:
return .none
}
case .purchaseItem:
return .none
}
}
}
}
And the updated view:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public struct PurchaseView: View {
@Bindable public var store: StoreOf<PurchaseFeature>
public var body: some View {
VStack(spacing: 20) {
Button("Close") {
store.send(.view(.closeButtonTapped))
}
Button("Purchase") {
store.send(.view(.purchaseButtonTapped))
}
}
}
}
Now, the view can only send actions defined in the View enum, while the reducer remains responsible for handling business logic actions.
Reducing Boilerplate with ViewAction Macro
While this solution works, having to use store.send(.view(.action)) can feel a bit verbose, especially if you have more nested actions. Thankfully, TCA provides a cleaner way to handle this with the @ViewAction macro.
By applying the @ViewAction macro to your view, you can send actions directly without explicitly referencing .view. Here’s the final version of the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@ViewAction(for: PurchaseFeature.self)
public struct PurchaseView: View {
@Bindable public var store: StoreOf<PurchaseFeature>
public var body: some View {
VStack(spacing: 20) {
Button("Close") {
send(.closeButtonTapped)
}
Button("Purchase") {
send(.purchaseButtonTapped)
}
}
}
}
Conclusion
By taking advantage of TCA’s ViewAction and the @ViewAction macro, you can keep your code clean, intuitive, and maintainable. Separating view actions from business logic helps maintain a clear boundary between the two, making it easier for you and future developers to understand the codebase.
☕ Support My Work
If you found this post helpful and want to support more content like this, you can buy me a coffee!
Your support helps me continue creating useful articles and tips for fellow developers. Thank you! 🙏