Skip to main content
The Todos example demonstrates how to build a full-featured list application using TCA. It showcases collections, bindings, filtering, and coordinated animations.

Overview

This example shows how to:
  • Manage collections of child features with forEach
  • Use @Bindable for two-way bindings
  • Filter and transform state for views
  • Coordinate animations with effects
  • Handle list operations (add, delete, move)

Implementation

import ComposableArchitecture
import SwiftUI

enum Filter: LocalizedStringKey, CaseIterable, Hashable {
  case all = "All"
  case active = "Active"
  case completed = "Completed"
}

@Reducer
struct Todos {
  @ObservableState
  struct State: Equatable {
    var editMode: EditMode = .inactive
    var filter: Filter = .all
    var todos: IdentifiedArrayOf<Todo.State> = []

    var filteredTodos: IdentifiedArrayOf<Todo.State> {
      switch filter {
      case .active: return self.todos.filter { !$0.isComplete }
      case .all: return self.todos
      case .completed: return self.todos.filter(\.isComplete)
      }
    }
  }

  enum Action: BindableAction, Sendable {
    case addTodoButtonTapped
    case binding(BindingAction<State>)
    case clearCompletedButtonTapped
    case delete(IndexSet)
    case move(IndexSet, Int)
    case sortCompletedTodos
    case todos(IdentifiedActionOf<Todo>)
  }

  @Dependency(\.continuousClock) var clock
  @Dependency(\.uuid) var uuid
  private enum CancelID { case todoCompletion }

  var body: some Reducer<State, Action> {
    BindingReducer()
    Reduce { state, action in
      switch action {
      case .addTodoButtonTapped:
        state.todos.insert(Todo.State(id: self.uuid()), at: 0)
        return .none

      case .binding:
        return .none

      case .clearCompletedButtonTapped:
        state.todos.removeAll(where: \.isComplete)
        return .none

      case .delete(let indexSet):
        let filteredTodos = state.filteredTodos
        for index in indexSet {
          state.todos.remove(id: filteredTodos[index].id)
        }
        return .none

      case .move(var source, var destination):
        if state.filter == .completed {
          source = IndexSet(
            source
              .map { state.filteredTodos[$0] }
              .compactMap { state.todos.index(id: $0.id) }
          )
          destination =
            (destination < state.filteredTodos.endIndex
              ? state.todos.index(id: state.filteredTodos[destination].id)
              : state.todos.endIndex)
            ?? destination
        }

        state.todos.move(fromOffsets: source, toOffset: destination)

        return .run { send in
          try await self.clock.sleep(for: .milliseconds(100))
          await send(.sortCompletedTodos)
        }

      case .sortCompletedTodos:
        state.todos.sort { $1.isComplete && !$0.isComplete }
        return .none

      case .todos(.element(id: _, action: .binding(\.isComplete))):
        return .run { send in
          try await self.clock.sleep(for: .seconds(1))
          await send(.sortCompletedTodos, animation: .default)
        }
        .cancellable(id: CancelID.todoCompletion, cancelInFlight: true)

      case .todos:
        return .none
      }
    }
    .forEach(\.todos, action: \.todos) {
      Todo()
    }
  }
}

Key Concepts

Collections with forEach

The forEach operator manages a collection of child features:
.forEach(\.todos, action: \.todos) {
  Todo()
}
This automatically:
  • Runs the Todo reducer for each element
  • Routes actions to the correct todo by ID
  • Maintains independent state for each todo

IdentifiedArray

IdentifiedArrayOf provides efficient collection management:
var todos: IdentifiedArrayOf<Todo.State> = []

// Add at beginning
state.todos.insert(Todo.State(id: uuid()), at: 0)

// Remove by ID
state.todos.remove(id: todoID)

// Remove by predicate
state.todos.removeAll(where: \.isComplete)

Bindings

BindingReducer enables two-way bindings:
var body: some Reducer<State, Action> {
  BindingReducer()  // Handles all binding actions
  Reduce { state, action in
    // Custom logic
  }
}
In the view:
@Bindable var store: StoreOf<Todo>

TextField("Untitled Todo", text: $store.description)

Computed State

Derived state for filtering:
var filteredTodos: IdentifiedArrayOf<Todo.State> {
  switch filter {
  case .active: return self.todos.filter { !$0.isComplete }
  case .all: return self.todos
  case .completed: return self.todos.filter(\.isComplete)
  }
}
Scope to filtered todos in the view:
ForEach(store.scope(state: \.filteredTodos, action: \.todos)) { store in
  TodoView(store: store)
}

Coordinated Animations

Delay sorting to allow animations to complete:
case .todos(.element(id: _, action: .binding(\.isComplete))):
  return .run { send in
    try await self.clock.sleep(for: .seconds(1))
    await send(.sortCompletedTodos, animation: .default)
  }
  .cancellable(id: CancelID.todoCompletion, cancelInFlight: true)

Observing Child Actions

The parent observes when a todo is completed and schedules sorting:
case .todos(.element(id: _, action: .binding(\.isComplete))):
  // A todo's completion status changed
  return .run { send in
    try await self.clock.sleep(for: .seconds(1))
    await send(.sortCompletedTodos, animation: .default)
  }

Testing

@Test
func testAddTodo() async {
  let store = TestStore(initialState: Todos.State()) {
    Todos()
  } withDependencies: {
    $0.uuid = .incrementing
  }

  await store.send(.addTodoButtonTapped) {
    $0.todos = [
      Todo.State(id: UUID(0))
    ]
  }
}

@Test
func testCompleteTodo() async {
  let store = TestStore(
    initialState: Todos.State(
      todos: [
        Todo.State(id: UUID(0), description: "Milk")
      ]
    )
  ) {
    Todos()
  }

  await store.send(.todos(.element(id: UUID(0), action: .binding(\.isComplete)))) {
    $0.todos[id: UUID(0)]?.isComplete = true
  }

  await store.receive(\.sortCompletedTodos) {
    // Completed todos moved to bottom
  }
}

Source Code

View the complete example in the TCA repository:

Next Steps

Build docs developers (and LLMs) love