Post

TCA Tips: The Power of ViewActions

Learn how ViewAction cleans up reducers in The Composable Architecture by handling view-specific actions.

TCA Tips: The Power of ViewActions

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:

  1. Conform the Action enum to the ViewAction protocol.
  2. Add a nested View enum inside Action for view-specific actions.
  3. Use a single case .view in the Action enum 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! 🙏

This post is licensed under CC BY 4.0 by the author.