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.