Milestone 1: search, MVVM, and dependency injection
Unit 24 · Project 4, Milestone 1. This milestone builds the search screen and establishes the
architecture the rest of Reeler reuses: an injected client, a @MainActor view model, and
debounced async work. Milestone 2 adds detail and pagination.
Every content app starts the same way: type a query, get results, handle the three things that can happen — nothing yet, loading, or an error. This milestone builds that spine correctly the first time, so you never retrofit it. The two decisions that matter are where the network call lives (behind a protocol, injected) and what state the screen can be in (a single enum, not a scatter of booleans).
The API client, behind a protocol
The view model must not know whether it's talking to a real server or a fake. Define the capability as a protocol; inject a concrete client at the call site.
import Foundation struct Film: Identifiable, Hashable, Sendable, Codable { let id: Int let title: String let year: Int let overview: String let director: String let genres: [String] let runtime: Int // minutes let posterPath: String? } protocol FilmAPIClient: Sendable { func search(_ query: String, page: Int) async throws -> [Film] }
Film is a struct — value semantics, Sendable for free because every stored property is
Sendable, so it crosses the actor boundary from the client to the main-actor view model
safely. The protocol is Sendable too, so the view model can hold it across isolation domains.
struct TMDBClient: FilmAPIClient { let apiKey: String private let decoder = JSONDecoder() func search(_ query: String, page: Int) async throws -> [Film] { var components = URLComponents(string: "https://api.example.com/search")! components.queryItems = [ .init(name: "query", value: query), .init(name: "page", value: String(page)), .init(name: "api_key", value: apiKey), ] let (data, response) = try await URLSession.shared.data(from: components.url!) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw APIError.badStatus } return try decoder.decode(SearchResponse.self, from: data).results } } enum APIError: Error { case badStatus }
Why inject it? A view model that constructs URLSession.shared itself can only be tested
by hitting the network. A view model handed a FilmAPIClient can be tested with a stub that
returns canned films — instantly, offline, deterministically. Dependency injection is not
ceremony; it is the difference between a testable view model and an untestable one.
The view model: one state enum, on the main actor
The view model owns the screen's state as a single value. Model the four things that can be
true with an enum, not four independent Bools that can contradict each other.
import Observation enum SearchState: Sendable { case idle case loading case loaded([Film]) case failed(String) } @Observable @MainActor final class SearchViewModel { var query = "" private(set) var state: SearchState = .idle private let client: FilmAPIClient init(client: FilmAPIClient) { self.client = client } func search() async { let trimmed = query.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { state = .idle; return } state = .loading do { let films = try await client.search(trimmed, page: 1) state = .loaded(films) } catch is CancellationError { // A newer keystroke cancelled this search — leave state alone. } catch { state = .failed("Couldn't load results. Check your connection.") } } }
@MainActor on the whole class means every property and method is main-actor isolated: SwiftUI
reads state on the main thread with no hopping, and you can't accidentally mutate it from a
background task. The client.search call is awaited — it suspends on a background executor,
then resumes back on the main actor to assign state. That hop is automatic and correct.
Catch CancellationError separately. When a newer keystroke cancels the in-flight search,
the awaited call throws CancellationError. If you fold that into the generic catch, a
cancelled search flashes an error banner. Swallow cancellation deliberately, as above.
Debouncing the search field
Firing a request on every keystroke is wasteful and races: "in" then "inc" then "ince" launches
three searches whose results can arrive out of order. Debounce by tying the work to the query
with .task(id:) and sleeping briefly first — a new keystroke changes the id, which cancels the
prior task before it ever calls the network.
struct SearchScreen: View { @State private var model: SearchViewModel init(client: FilmAPIClient) { _model = State(initialValue: SearchViewModel(client: client)) } var body: some View { List { switch model.state { case .idle: ContentUnavailableView("Search for a film", systemImage: "magnifyingglass") case .loading: ProgressView() case .loaded(let films): ForEach(films) { film in Text(film.title) } case .failed(let message): ContentUnavailableView(message, systemImage: "exclamationmark.triangle") } } .searchable(text: $model.query) .task(id: model.query) { try? await Task.sleep(for: .milliseconds(300)) await model.search() } } }
The flow: the user types, model.query changes, .task(id:) cancels the previous task and
starts a new one, which sleeps 300 ms. If another keystroke lands inside that window, the sleep
is cancelled (it throws, caught by try?) and model.search() is never reached. Only when
typing pauses for 300 ms does a request actually fire. This is debouncing built out of
structured concurrency — no timers, no DispatchQueue, no manual cancellation bookkeeping.
In Kotlin you'd expose the query as a StateFlow and write
query.debounce(300).flatMapLatest { search(it) }. flatMapLatest cancelling the previous
search is exactly what .task(id:) does here — a change to the id cancels the old task. Swift
gives you the same "latest wins" behavior through structured concurrency's cancellation, without
an operator.
Checkpoint
Your app should now: show a search field, debounce input, load results through an injected
client, and render distinct idle / loading / loaded / error states. Construct the screen with a
real TMDBClient in the app and a stub in previews and tests.
Stretch: write a StubFilmAPIClient that returns a fixed array, then a Swift Testing test
that constructs SearchViewModel(client: stub), calls await search(), and asserts
state is .loaded with the expected films.
Knowledge check
Q: Why is SearchState a single enum instead of isLoading, films, and errorMessage?
Independent flags can encode impossible states (loading and failed at once). A single enum
makes the state machine explicit — the screen is in exactly one case, and the compiler forces
you to handle each in the view's switch.
Q: How does .task(id: model.query) debounce without a timer?
Changing the id cancels the running task and starts a new one. The task sleeps 300 ms before
calling the network; a fresh keystroke cancels that sleep before the request fires. Only a pause
in typing lets a request through — latest keystroke wins.