Skip to main content
The Search example demonstrates how to build a live search feature with debouncing, API requests, and automatic cancellation. It’s a perfect introduction to effects and async operations in TCA.

Overview

This example shows how to:
  • Debounce user input with SwiftUI’s task modifier
  • Make API requests with URLSession
  • Cancel in-flight requests automatically
  • Handle loading and error states
  • Define custom dependency clients
  • Test async behavior

Implementation

import ComposableArchitecture
import SwiftUI

@Reducer
struct Search {
  @ObservableState
  struct State: Equatable {
    var results: [GeocodingSearch.Result] = []
    var resultForecastRequestInFlight: GeocodingSearch.Result?
    var searchQuery = ""
    var weather: Weather?

    struct Weather: Equatable {
      var id: GeocodingSearch.Result.ID
      var days: [Day]

      struct Day: Equatable {
        var date: Date
        var temperatureMax: Double
        var temperatureMaxUnit: String
        var temperatureMin: Double
        var temperatureMinUnit: String
      }
    }
  }

  enum Action {
    case forecastResponse(
      GeocodingSearch.Result.ID,
      Result<Forecast, any Error>
    )
    case searchQueryChanged(String)
    case searchQueryChangeDebounced
    case searchResponse(Result<GeocodingSearch, any Error>)
    case searchResultTapped(GeocodingSearch.Result)
  }

  @Dependency(\.weatherClient) var weatherClient
  private enum CancelID { case location, weather }

  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .forecastResponse(_, .failure):
        state.weather = nil
        state.resultForecastRequestInFlight = nil
        return .none

      case .forecastResponse(let id, .success(let forecast)):
        state.weather = State.Weather(
          id: id,
          days: forecast.daily.time.indices.map {
            State.Weather.Day(
              date: forecast.daily.time[$0],
              temperatureMax: forecast.daily.temperatureMax[$0],
              temperatureMaxUnit: forecast.dailyUnits.temperatureMax,
              temperatureMin: forecast.daily.temperatureMin[$0],
              temperatureMinUnit: forecast.dailyUnits.temperatureMin
            )
          }
        )
        state.resultForecastRequestInFlight = nil
        return .none

      case .searchQueryChanged(let query):
        state.searchQuery = query

        guard !state.searchQuery.isEmpty else {
          state.results = []
          state.weather = nil
          return .cancel(id: CancelID.location)
        }
        return .none

      case .searchQueryChangeDebounced:
        guard !state.searchQuery.isEmpty else {
          return .none
        }
        return .run { [query = state.searchQuery] send in
          await send(
            .searchResponse(
              Result { try await self.weatherClient.search(query: query) }
            )
          )
        }
        .cancellable(id: CancelID.location)

      case .searchResponse(.failure):
        state.results = []
        return .none

      case .searchResponse(.success(let response)):
        state.results = response.results
        return .none

      case .searchResultTapped(let location):
        state.resultForecastRequestInFlight = location

        return .run { send in
          await send(
            .forecastResponse(
              location.id,
              Result {
                try await self.weatherClient.forecast(location: location)
              }
            )
          )
        }
        .cancellable(id: CancelID.weather, cancelInFlight: true)
      }
    }
  }
}

Key Concepts

Debouncing with SwiftUI

Use .task(id:) for automatic debouncing:
.task(id: store.searchQuery) {
  do {
    try await Task.sleep(for: .milliseconds(300))
    await store.send(.searchQueryChangeDebounced).finish()
  } catch {}
}
When searchQuery changes:
  1. Previous task is automatically cancelled
  2. New task waits 300ms
  3. If not cancelled, sends the debounced action

Automatic Cancellation

Mark effects as cancellable:
return .run { [query = state.searchQuery] send in
  await send(
    .searchResponse(
      Result { try await self.weatherClient.search(query: query) }
    )
  )
}
.cancellable(id: CancelID.location)
When the search query changes, previous requests are automatically cancelled.

Cancel in Flight

Cancel and replace in-flight requests:
return .run { send in
  await send(
    .forecastResponse(
      location.id,
      Result {
        try await self.weatherClient.forecast(location: location)
      }
    )
  )
}
.cancellable(id: CancelID.weather, cancelInFlight: true)
Only the most recent forecast request will complete.

Clearing State

Clear results when query is cleared:
case .searchQueryChanged(let query):
  state.searchQuery = query

  guard !state.searchQuery.isEmpty else {
    state.results = []
    state.weather = nil
    return .cancel(id: CancelID.location)
  }
  return .none

Loading States

Track in-flight requests:
var resultForecastRequestInFlight: GeocodingSearch.Result?

case .searchResultTapped(let location):
  state.resultForecastRequestInFlight = location
  // Make request...

case .forecastResponse(let id, .success(let forecast)):
  state.weather = /* parse forecast */
  state.resultForecastRequestInFlight = nil
  return .none
Show loading indicator in view:
if store.resultForecastRequestInFlight?.id == location.id {
  ProgressView()
}

Custom Dependencies

Define the client interface:
@DependencyClient
struct WeatherClient {
  var forecast: @Sendable (
    _ location: GeocodingSearch.Result
  ) async throws -> Forecast
  var search: @Sendable (
    _ query: String
  ) async throws -> GeocodingSearch
}
Provide live and test implementations:
extension WeatherClient: DependencyKey {
  static let liveValue = WeatherClient(
    forecast: { /* real API call */ },
    search: { /* real API call */ }
  )
}

extension WeatherClient: TestDependencyKey {
  static let testValue = WeatherClient(
    forecast: { _ in .mock },
    search: { _ in .mock }
  )
}

Testing

@Test
func testSearchAndForecast() async {
  let store = TestStore(initialState: Search.State()) {
    Search()
  } withDependencies: {
    $0.weatherClient.search = { _ in
      GeocodingSearch(
        results: [
          GeocodingSearch.Result(
            country: "US",
            latitude: 40.7,
            longitude: -74.0,
            id: 1,
            name: "New York",
            admin1: nil
          )
        ]
      )
    }
    $0.weatherClient.forecast = { _ in .mock }
  }

  await store.send(.searchQueryChanged("New York")) {
    $0.searchQuery = "New York"
  }

  await store.send(.searchQueryChangeDebounced)

  await store.receive(\.searchResponse.success) {
    $0.results = [
      GeocodingSearch.Result(
        country: "US",
        latitude: 40.7,
        longitude: -74.0,
        id: 1,
        name: "New York",
        admin1: nil
      )
    ]
  }

  await store.send(.searchResultTapped($0.results[0])) {
    $0.resultForecastRequestInFlight = $0.results[0]
  }

  await store.receive(\.forecastResponse) {
    $0.weather = /* expected weather */
    $0.resultForecastRequestInFlight = nil
  }
}

@Test
func testClearQuery() async {
  let store = TestStore(
    initialState: Search.State(
      results: [
        GeocodingSearch.Result(
          country: "US",
          latitude: 40.7,
          longitude: -74.0,
          id: 1,
          name: "New York",
          admin1: nil
        )
      ],
      searchQuery: "New York"
    )
  ) {
    Search()
  }

  await store.send(.searchQueryChanged("")) {
    $0.searchQuery = ""
    $0.results = []
    $0.weather = nil
  }
}

Source Code

View the complete example in the TCA repository:

Next Steps

Build docs developers (and LLMs) love