Milestone 1: search, MVVM, and dependency injectionswift-6.4/ios-26
Lesson 2 / 6
Unit 24 · Reeler

Milestone 1: search, MVVM, and dependency injection

Coming from
Note

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 }
Tip

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.

Note

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.

Coming from Kotlin

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.