TCA - send an event from one reducer to another/modify the state of a different store

2.3k Views Asked by At

I'm new to TCA (The Composable Architecture) so I may have the entirely wrong approach here, so apologies if so I and I would appreciate getting pointed in the right direction. It's a little bit tricky to find resources for this since I've adopted the new ReducerProtocol approach to TCA and the vast majority of the tutorials/documentation I can find uses the old approach.

Essentially imagine I have two reducers, TodoListFeature and TodoItemFeature. The former displays a list of todo items, and the latter displays a single todo item, and allows editing of its information (e.g. title).

So TodoListFeature might look like this:


struct TodoListFeature: ReducerProtocol {
    
   struct State {
      var todoItems: [TodoItem]
   }

   enum Action {
      case onAppear
      case dataLoaded(Result<[TodoItem], Error>)
   }

   func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    
       switch action {
       case .onAppear:
           // perform async action to fetch todos, then dispatch .dataLoaded
       case .dataLoaded(let result):
           // update state
       }
   }
}

and then TodoItemFeature something like this:

struct TodoItemFeature: ReducerProtocol {
    
   struct State {
      var saveError: Error?
   }

   enum Action {
      case updatedItem(TodoItem)
   }

   func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    
       switch action {
       case .updateItem(let item):
           // perform async action to fetch todos, then dispatch .dataLoaded
           // But then what?
       }
   }
}

So this is the question - see the "But then what" comment? Essentially I now want to take that updated item, and update my state in TodoListFeature - in other words, I'd like to update the list.

I've read about composition, but this just seems to be arbitrarily grouping together features into a single store and then scoping that single store depending on the view we're using. How do I get reducers to talk to one another?

2

There are 2 best solutions below

0
On

Since TCA allows unidirectional data flow, you can always access the child state from the parent reducer. But to answer your first question which is how to make the reducers talk with each other, you should start with integrating the child reducer into the parent domain. Here I updated your code to provide communication between the reducers:

struct TodoItem: Identifiable, Equatable {
  let id = UUID()
  var title: String
}
struct TodoListFeature: Reducer {
  struct State {
    var todoItems: [TodoItem] = []
    @PresentationState var editTodoState: TodoItemFeature.State?
    var error: Error?
  }
  
  enum Action {
    case todoItem(PresentationAction<TodoItemFeature.Action>)
    case onAppear
    case dataLoaded(Result<[TodoItem], Error>)
    case todoTapped(TodoItem)
  }
  
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .onAppear:
        return .task {
          try? await Task.sleep(nanoseconds: 2_000_000_000)
          return .dataLoaded(.success([
            .init(title: "First todo"),
            .init(title: "Second todo"),
            .init(title: "Third todo")
          ]))
        }
      case let .dataLoaded(.success(data)):
        state.todoItems = data
        return .none
      case let .dataLoaded(.failure(error)):
        state.error = error
        return .none
      case let .todoItem(.presented(.saveTapped(item))):
        defer { state.editTodoState = nil }
        guard let index = state.todoItems.firstIndex(where: { $0.id == item.id}) else { return .none }
        state.todoItems[index] = item
        return .none
      case let .todoTapped(todoItem):
        state.editTodoState = .init(todoItem: todoItem)
        return .none
      case .todoItem(.presented(.cancelTapped)):
        state.editTodoState = nil
        return .none
      case .todoItem:
        return .none
      }
    }
    .ifLet(\.$editTodoState, action: /Action.todoItem) {
      TodoItemFeature()
    }
  }
}

struct TodoListView: View {
  let store: StoreOf<TodoListFeature>
  var body: some View {
    WithViewStore(self.store, observe: \.todoItems) { viewStore in
      List(viewStore.state) { todoItem in
        Text(todoItem.title)
          .onTapGesture {
            viewStore.send(.todoTapped(todoItem))
          }
      }
      .onAppear {
        viewStore.send(.onAppear)
      }
      .sheet(
        store: self.store.scope(
          state: \.$editTodoState,
          action: TodoListFeature.Action.todoItem
        )
      ) {
        TodoItemView(store: $0)
      }
    }
  }
}

struct TodoItemFeature: Reducer {
  
  struct State: Equatable, Identifiable {
    var id: UUID { todoItem.id }
    @BindingState var todoItem: TodoItem
  }
  
  enum Action: BindableAction {
    case binding(BindingAction<State>)
    case saveTapped(TodoItem)
    case cancelTapped
  }
  
  var body: some ReducerOf<Self> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      default:
        return .none
      }
    }
  }
}


struct TodoItemView: View {
  let store: StoreOf<TodoItemFeature>
  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      NavigationView {
        Form {
          TextField("", text: viewStore.binding(\.$todoItem.title))
        }
        .toolbar {
          ToolbarItem(placement: .primaryAction) {
            Button("Save") {
              viewStore.send(.saveTapped(viewStore.todoItem))
            }
          }
          
          ToolbarItem(placement: .cancellationAction) {
            Button("Cancel") {
              viewStore.send(.cancelTapped)
            }
          }
        }
      }
    }
  }
}

In this example, we first render TodoListView and call the onAppear action when the view appeared. We run an async task to simulate fetching todos from some external storage after 2 seconds and render each todos in a list. Here you can also handle loading state and possible errors that can occur while fetching the data. When you tap a todo item from the list, it displays a sheet with editing feature of the corresponding todo. To inform the parent reducer that a todo is updated, we have the saveTapped enum case action which takes a todoItem parameter (or anything that you want to update) and we call this action once save button is tapped. At that point we can listen to the call of this action from the parent reducer with the help of todoItem presentation action and make the required changes here.

Although this approach totally works and it is fine for the smaller applications, in larger applications, this way can easily lead to complicated code and hard-to-understand bugs. The reason is Enums in Swift does not have any access control and you can easily go trough non-exhaustive integration of the enums once your actions get extended.

Let me explain what I'm trying to say here with an example. Let's say you or some other developer wanted to add some more actions to your TodoItemFeature reducer, such as deleting a todo, and you forgot to implement it in the parent reducer while developing (which would be a very basic mistake but let us assume this is the case for now ). In this case you will not be getting a compile time error or warning, since you already implemented .todoItem case in your reducer. And believe me when the app gets larger, it is quite easy to miss such side effects if you are not getting a compile time error. This is why we want to utilize compile time errors as much as possible.

To prevent such mistakes, we can leverage from the Delegate pattern who is our old friend that we generally come across in UIKit applications. Here I updated the reducers and TodoItemView's toolbar with delegate actions.


// Updated TodoItemFeature Action

enum Action: BindableAction {
    case binding(BindingAction<State>)
    case delegate(Delegate)
    
    enum Delegate {
      case saveTapped(TodoItem)
      case cancelTapped
    }
  }

// Updated TodoListFeature body property

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .onAppear:
        return .task {
          try? await Task.sleep(nanoseconds: 2_000_000_000)
          return .dataLoaded(.success([
            .init(title: "First todo"),
            .init(title: "Second todo"),
            .init(title: "Third todo")
          ]))
        }
      case let .dataLoaded(.success(data)):
        state.todoItems = data
        return .none
      case let .dataLoaded(.failure(error)):
        state.error = error
        return .none
      case let .todoItem(.presented(.delegate(action))):
        switch action {
        case .cancelTapped:
          state.editTodoState = nil
          return .none
        case let .saveTapped(item):
          defer { state.editTodoState = nil }
          guard let index = state.todoItems.firstIndex(where: { $0.id == item.id}) else { return .none }
          state.todoItems[index] = item
          return .none
        }
      case let .todoTapped(todoItem):
        state.editTodoState = .init(todoItem: todoItem)
        return .none
      case .todoItem:
        return .none
      }
    }
    .ifLet(\.$editTodoState, action: /Action.todoItem) {
      TodoItemFeature()
    }
  }

// Updated TodoItemView toolbar view modifier

.toolbar {
  ToolbarItem(placement: .primaryAction) {
     Button("Save") {
      viewStore.send(.delegate(.saveTapped(viewStore.todoItem)))
    }
  }
          
  ToolbarItem(placement: .cancellationAction) {
    Button("Cancel") {
      viewStore.send(.delegate(.cancelTapped))
    }
  }
}

With this implementation, if you modify the delegate actions from the child reducer, the app will not compile until you implement that change in the parent domain as well which prevents possible bugs to happen in your application.

If you want, you can further improve your Action enum by splitting internal and ui actions as well. I would recommend you to have a look at this very nice article written by Krzysztof Zabłocki about the action boundaries in TCA and the best practices to get a good grasp of TCA and unidirectional data flow.

0
On

I'm trying to work this out too, and although in the early stages of investigation, haven't found an answer.

My only possible solution for your question though would be to ignore performing the async action to fetch todos, then dispatching .dataLoaded and just dismiss that view. You already have the .onAppear action set in the TodoListFeature` so that would be triggered when the TodoItemFeature view is dismissed.