With part 4 we have favorites that persist and sync themselves. Now we add two things that always show up together in a real app: search by name and filter by type. Each touches a different TCA concept.
Search is the textbook case of debounce with in-flight cancellation. We already saw the pattern on an isolated example in TCA part 1; here we integrate it into the real app, with the bugs that show up when the user already has data loaded.
Filters are the Pokédex’s first “real” sub-feature. We model them as their own Reducer with BindingReducer for the toggles, compose them into the parent with Scope, and the parent reacts to the child’s actions to reload the list.
Search: state first
Search isn’t a separate mode of the app: it lives alongside the list. The user can start typing at any moment and go back to the listing effortlessly. Instead of two parallel states (.browsing / .searching), a single LoadState<[Pokemon]> that updates as needed:
@ObservableState
struct State: Equatable {
var loadState: LoadState<[Pokemon]> = .idle
var isLoadingMore = false
var canLoadMore = true
@Presents var destination: Destination.State?
@SharedReader(.favorites) var favorites
// new:
var query: String = ""
}
The new actions:
enum Action {
// ... previous actions ...
case queryChanged(String)
case searchReceived([Pokemon])
case searchFailed(String)
}
And a CancelID for search:
private enum CancelID { case initial, more, search }
The search reducer
case let .queryChanged(text):
state.query = text
guard !text.isEmpty else {
// User cleared the query: cancel the in-flight search
// and go back to the initial listing.
state.loadState = .idle
return .merge(
.cancel(id: CancelID.search),
.send(.onAppear)
)
}
state.loadState = .loading
return .run { [pokemonClient, clock] send in
try await clock.sleep(for: .milliseconds(300))
let results = try await pokemonClient.search(text)
await send(.searchReceived(results))
} catch: { error, send in
// Filter cancellations: they aren't errors for the user.
if error is CancellationError { return }
await send(.searchFailed(error.localizedDescription))
}
.cancellable(id: CancelID.search, cancelInFlight: true)
case let .searchReceived(results):
state.loadState = .loaded(results)
state.canLoadMore = false
return .none
case let .searchFailed(message):
state.loadState = .failed(message)
return .none
Five things worth flagging.
One, when the search is cleared we go back to .onAppear. That’s the nice thing about having named actions: reusing one is a line. .merge lets you fire two effects in the same cycle (cancel the search and start the initial load).
Two, clock.sleep(for: .milliseconds(300)) is the debounce. I swapped Task.sleep for an injected dependency (@Dependency(\.continuousClock) var clock) back in part 1; it pays off here because the test for this flow will be instantaneous with an ImmediateClock.
Three, cancelInFlight: true is the protection: if the user types fast, each new keystroke cancels the previous search. Only the last one survives. Without it, stale results arrive late and overwrite fresh ones.
Four, we filter CancellationError in the catch. Cancellation also throws, and without this filter every letter that cancels an in-flight search would put .failed("cancelled") on screen for half a second.
Five, canLoadMore = false on .searchReceived. The PokeAPI doesn’t paginate search results the same way as the listing, so we disable pagination while a search is active. When the user clears the query and we go back to .onAppear, the initial listing paginates again.
We need to add search to the client:
struct PokemonClient {
var fetchList: (_ limit: Int, _ offset: Int) async throws -> [Pokemon]
var fetchDetail: (_ id: Int) async throws -> PokemonDetail
var search: (_ query: String) async throws -> [Pokemon]
}
Filters as a sub-feature
Filters are a good candidate for a sub-feature because:
- They have their own state (the selected types).
- They have their own UI (a row of chips or a sheet).
- They could tomorrow be used from another screen (say, filtered favorites).
@Reducer
struct FiltersFeature {
@ObservableState
struct State: Equatable {
var selectedTypes: Set<PokemonType> = []
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case clearTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .clearTapped:
state.selectedTypes = []
return .none
case .binding:
return .none
}
}
}
}
BindingReducer is the shortcut that avoids writing one action per field. With this and @Bindable var store in the view, a Toggle(isOn: $store.selectedTypes.contains(...)) or a NavigationLink that mutates selectedTypes works without wiring anything else.
Composing in the parent
With Scope we connect the sub-feature to the parent’s state:
@Reducer
struct PokemonListFeature {
@ObservableState
struct State: Equatable {
// ... previous fields ...
var filters = FiltersFeature.State()
}
enum Action {
// ... previous actions ...
case filters(FiltersFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.filters, action: \.filters) {
FiltersFeature()
}
Reduce { state, action in
switch action {
// ... previous cases ...
case .filters(.binding(\.selectedTypes)):
// When the user changes filters, reload the list
// applying the new criteria.
return startInitialLoad(state: &state)
case .filters:
return .none
}
}
.ifLet(\.$destination, action: \.destination) {
Destination()
}
}
}
Notice case .filters(.binding(\.selectedTypes)): the parent reacts specifically when the child changes selectedTypes. The child doesn’t know there’s a parent listening; the parent decides what a filter change means (in this case, reload). That’s real composition: explicit dependencies, no delegates, no NotificationCenter.
startInitialLoad now needs to read the filters to pass them to the client:
private func startInitialLoad(state: inout State) -> Effect<Action> {
state.loadState = .loading
let types = state.filters.selectedTypes
return .run { [pokemonClient, pageSize] send in
let list = try await pokemonClient.fetchList(pageSize, 0, types)
await send(.listReceived(list))
} catch: { error, send in
await send(.listFailed(error.localizedDescription))
}
.cancellable(id: CancelID.initial, cancelInFlight: true)
}
fetchList gains a parameter (Set<PokemonType>) that the live implementation translates to PokeAPI query params. In the types section, the PokeAPI serves through a different endpoint (/type/{name}), and the PokemonClient hides the difference. This is what in PokeTracker part 4 we solved with the PokemonRepository; the responsibility is identical here, only the shape of the type changes.
The list view with search and filters
struct PokemonListView: View {
@Bindable var store: StoreOf<PokemonListFeature>
var body: some View {
contentView
.navigationTitle("Pokédex")
.searchable(text: $store.query.sending(\.queryChanged))
.toolbar {
ToolbarItem(placement: .primaryAction) {
NavigationLink {
FiltersView(store: store.scope(state: \.filters, action: \.filters))
} label: {
Image(systemName: store.filters.selectedTypes.isEmpty
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
}
}
}
.navigationDestination(
item: $store.scope(\.destination, action: \.destination).detail
) { detailStore in
PokemonDetailView(store: detailStore)
}
.onAppear { store.send(.onAppear) }
}
@ViewBuilder
private var contentView: some View {
switch store.loadState {
case .idle, .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case let .loaded(pokemons) where pokemons.isEmpty:
ContentUnavailableView.search
case let .loaded(pokemons):
loadedList(pokemons)
case let .failed(message):
errorView(message)
}
}
// loadedList and errorView as in part 2, with the favorites row
// integrated as in part 4.
}
$store.query.sending(\.queryChanged) is the modern pattern for binding to actions: when .searchable changes the text, .queryChanged(newText) is sent to the store. The view doesn’t mutate state, just describes what to do with the change.
Note the extra case: case let .loaded(pokemons) where pokemons.isEmpty shows ContentUnavailableView.search, iOS’s standard “no results” view. We handle it here instead of adding another LoadState case because it’s a UI rule, not a semantically distinct state.
The filters view
struct FiltersView: View {
@Bindable var store: StoreOf<FiltersFeature>
var body: some View {
Form {
Section("Types") {
ForEach(PokemonType.allCases) { type in
Toggle(type.displayName, isOn: Binding(
get: { store.selectedTypes.contains(type) },
set: { on in
var next = store.selectedTypes
if on { next.insert(type) } else { next.remove(type) }
store.selectedTypes = next
}
))
}
}
if !store.selectedTypes.isEmpty {
Section {
Button("Clear", role: .destructive) {
store.send(.clearTapped)
}
}
}
}
.navigationTitle("Filters")
}
}
When we write store.selectedTypes = next, BindingReducer generates the .binding(\.selectedTypes) action automatically, which the parent is listening to with the case .filters(.binding(\.selectedTypes)) above. That’s all the communication needed.
What we’ve got
- Search with debounce, in-flight cancellation and
CancellationErrorfiltering. - The same feature shows listing or search depending on whether there’s a query.
- Filters as a sub-feature with
BindingReducer. - Composition with
Scopeand the parent reacting to the child’s actions. - A single
LoadStatefor list, search and filters: all three are “loading a list”.
What’s next
With this we have the complete app: list with pagination, detail with extra data, persisted favorites, search and filters. What’s missing is the chapter we covered in PokeTracker part 9 with unit tests of view models and snapshots: in part 6 we write tests with TestStore that cover complete flows (“load list, tap row, mark favorite, go back”) and close the series with the honest retrospective: what TCA duplicated, what it simplified, what hurts in production, and in which scenario I’d pick TCA over MVVM for a real app.
Sources: swift-composable-architecture · PokeAPI · app repo