Overview
This example shows how to:- Debounce user input with SwiftUI’s
taskmodifier - 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)
}
}
}
}
import ComposableArchitecture
import Foundation
@DependencyClient
struct WeatherClient {
var forecast: @Sendable (
_ location: GeocodingSearch.Result
) async throws -> Forecast
var search: @Sendable (
_ query: String
) async throws -> GeocodingSearch
}
extension WeatherClient: DependencyKey {
static let liveValue = WeatherClient(
forecast: { result in
var components = URLComponents(
string: "https://api.open-meteo.com/v1/forecast"
)!
components.queryItems = [
URLQueryItem(name: "latitude", value: "\(result.latitude)"),
URLQueryItem(name: "longitude", value: "\(result.longitude)"),
URLQueryItem(
name: "daily",
value: "temperature_2m_max,temperature_2m_min"
),
URLQueryItem(
name: "timezone",
value: TimeZone.autoupdatingCurrent.identifier
),
]
let (data, _) = try await URLSession.shared.data(
from: components.url!
)
return try jsonDecoder.decode(Forecast.self, from: data)
},
search: { query in
var components = URLComponents(
string: "https://geocoding-api.open-meteo.com/v1/search"
)!
components.queryItems = [URLQueryItem(name: "name", value: query)]
let (data, _) = try await URLSession.shared.data(
from: components.url!
)
return try jsonDecoder.decode(GeocodingSearch.self, from: data)
}
)
}
extension DependencyValues {
var weatherClient: WeatherClient {
get { self[WeatherClient.self] }
set { self[WeatherClient.self] = newValue }
}
}
struct GeocodingSearch: Decodable, Equatable, Sendable {
var results: [Result]
struct Result: Decodable, Equatable, Identifiable, Sendable {
var country: String
var latitude: Double
var longitude: Double
var id: Int
var name: String
var admin1: String?
}
}
struct Forecast: Decodable, Equatable, Sendable {
var daily: Daily
var dailyUnits: DailyUnits
struct Daily: Decodable, Equatable, Sendable {
var temperatureMax: [Double]
var temperatureMin: [Double]
var time: [Date]
}
struct DailyUnits: Decodable, Equatable, Sendable {
var temperatureMax: String
var temperatureMin: String
}
}
struct SearchView: View {
@Bindable var store: StoreOf<Search>
var body: some View {
NavigationStack {
VStack(alignment: .leading) {
HStack {
Image(systemName: "magnifyingglass")
TextField(
"New York, San Francisco, ...",
text: $store.searchQuery.sending(\.searchQueryChanged)
)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
}
.padding(.horizontal, 16)
List {
ForEach(store.results) { location in
VStack(alignment: .leading) {
Button {
store.send(.searchResultTapped(location))
} label: {
HStack {
Text(location.name)
if store.resultForecastRequestInFlight?.id
== location.id
{
ProgressView()
}
}
}
if location.id == store.weather?.id {
weatherView(locationWeather: store.weather)
}
}
}
}
}
.navigationTitle("Search")
}
.task(id: store.searchQuery) {
do {
try await Task.sleep(for: .milliseconds(300))
await store.send(.searchQueryChangeDebounced).finish()
} catch {}
}
}
@ViewBuilder
func weatherView(
locationWeather: Search.State.Weather?
) -> some View {
if let locationWeather {
let days = locationWeather.days
.enumerated()
.map { idx, weather in
formattedWeather(day: weather, isToday: idx == 0)
}
VStack(alignment: .leading) {
ForEach(days, id: \.self) { day in
Text(day)
}
}
.padding(.leading, 16)
}
}
}
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 {}
}
searchQuery changes:
- Previous task is automatically cancelled
- New task waits 300ms
- 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)
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)
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
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
}
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
- Learn about effects in depth
- Explore dependencies for testing
- See testing async effects