Documentation Index Fetch the complete documentation index at: https://mintlify.com/pointfreeco/swift-composable-architecture/llms.txt
Use this file to discover all available pages before exploring further.
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
Reducer
Weather Client
View
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:
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 )
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