Staybnb M2 — filtering and searchswift-6.4/ios-26
Lesson 3 / 6
Unit 27 · Staybnb

Staybnb M2 — filtering and search

Coming from
Not covered in this lesson — showing TypeScript.
Note

Unit 27 · Staybnb Milestone 2. You build the filter model and the search that applies it. The habits here — pure predicates, work out of body — are exactly what the AI checkpoint tests.

Filtering looks trivial until the catalog is large and the filters are many, and then two things bite: correctness (composing four independent criteria without a bug) and performance (not re-filtering the whole catalog on every keystroke inside body). This milestone gets both right by keeping the filter a plain value type and the filtering a pure function.

The filter model

A Filter is a value type — a snapshot of what the user has chosen. It knows nothing about views; it is just data.

import Foundation

struct Filter: Equatable, Sendable {
    var maxPrice: Int = 500
    var minGuests: Int = 1
    var requiredAmenities: Set<String> = []
    var dateRange: ClosedRange<Date>?

    var isActive: Bool {
        maxPrice < 500 || minGuests > 1 || !requiredAmenities.isEmpty || dateRange != nil
    }
}

Making Filter Equatable matters: SwiftUI (and your own code) can compare the old and new filter and only recompute when it actually changed. isActive powers a "reset" affordance and a badge on the filter button.

Composing predicates

Each criterion is an independent predicate. Compose them with allSatisfy, so adding a filter is adding one clause — not editing a tangled boolean.

extension Filter {
    func matches(_ listing: Listing) -> Bool {
        let predicates: [(Listing) -> Bool] = [
            { $0.pricePerNight <= maxPrice },
            { $0.guests >= minGuests },
            { requiredAmenities.isSubset(of: $0.amenities) },
        ]
        return predicates.allSatisfy { $0(listing) }
    }

    func apply(to listings: [Listing]) -> [Listing] {
        listings.filter(matches)
    }
}

requiredAmenities.isSubset(of:) is why amenities is a Set — "listing has all the required amenities" is one cheap call. apply(to:) is a pure function of (Filter, [Listing]): same inputs, same output, no view state, trivially testable. That purity is the whole point.

Tip

Keep filtering a pure function, never a closure that reaches into view state. A pure apply(to:) can be unit-tested, run off the main actor, and — critically — called once when inputs change instead of re-running every time SwiftUI evaluates body.

Where filtering runs (the performance rule)

This is the crux, and the checkpoint punishes getting it wrong. Do not filter in body. A computed property that filters, read from body, re-runs the entire filter on every render — every keystroke, every unrelated state change. Instead, compute the filtered results when the inputs change and store them.

@MainActor
@Observable
final class SearchModel {
    var filter = Filter() { didSet { recompute() } }
    private(set) var results: [Listing] = []
    private var allListings: [Listing] = []

    func load(_ listings: [Listing]) {
        allListings = listings
        recompute()
    }

    private func recompute() {
        results = filter.apply(to: allListings)   // once, on change — not in body
    }
}

The view reads model.results — an already-computed array — and renders it. Filtering happens in recompute(), driven by didSet, so it runs exactly when the filter changes, not on every body evaluation. For a large catalog you can push recompute off the main actor and hop back to assign, but the structural fix — work out of body — comes first.

Coming from TypeScript

In React you would reach for useMemo so an expensive filter only recomputes when its dependencies change, avoiding a recompute on every render. SwiftUI's body has the same trap: work inlined into body is your "recompute on every render." The Swift equivalent of useMemo is not a hook — it is moving the work out of body into a didSet/model method that runs on change. Same principle, structural instead of API-level.

Categories and wishlists

Two more surfaces ride on this same model. A category row (Amazing views, Beachfront, Cabins…) is one more predicate on the search model — a stored category that recomputes results on change, exactly like the filter:

@Observable @MainActor
final class SearchModel {
    var filter = Filter() { didSet { recompute() } }
    var category = "All" { didSet { recompute() } }
    private(set) var results: [Listing] = []
    private var all: [Listing] = []

    func load(_ listings: [Listing]) { all = listings; recompute() }
    private func recompute() {
        results = filter.apply(to: all).filter { category == "All" || $0.category == category }
    }
}

Wishlists are a small piece of shared state read by three places — the heart on a card, the heart on the detail, and the Wishlists tab. Model it as one @Observable store injected through the environment:

@Observable @MainActor
final class Wishlist {
    private(set) var saved: Set<UUID> = []
    func contains(_ id: UUID) -> Bool { saved.contains(id) }
    func toggle(_ id: UUID) { if saved.contains(id) { saved.remove(id) } else { saved.insert(id) } }
}

// at the app root:
// ContentView().environment(Wishlist())

Now any view reads it with @Environment(Wishlist.self): the card's heart toggles it, the Wishlists tab filters the catalog by it, and every heart in the app reflects the same set. One store, many readers — no notifications, no syncing. (A real build persists saved with SwiftData or a wishlists table; the store is the seam either way.)

Tip

Category, filter, and wishlist are three flavors of the same idea: derive what you show from a small piece of state, in one place. The category and filter derive the results on change; the wishlist derives the heart everywhere it appears. Keep the deriving out of body and there is one truth to reason about.

Server-side search (preview)

For a large catalog the initial filter runs server-side in the edge function you build in Milestone 3: the app sends the Filter as query params, the function returns a ranked, priced page. The device then does last-mile filtering on that page as the user tweaks — the same pure apply(to:), now over a few hundred rows instead of the whole table.

Your turn

The exercise below is the pure filter logic from this milestone, isolated and gradable — exactly the function that belongs outside body. Implement it as filter + map over value types.

Pure filter logic for listings
Edit the code on the right, then run the hidden tests.

Checkpoint: your app should now…

  • Have a Filter value type composing price, guests, amenities, and dates.
  • Apply it with a pure apply(to:) function that is unit-tested.
  • Recompute results on filter change (in a model, via didSet) — never inside body.
  • Filter by category from the explore row, and save/unsave a listing via an @Observable Wishlist in the environment that a heart and the Wishlists tab share.

Stretch goals

  • Debounce a free-text title search so it recomputes after typing settles, not per keystroke.
  • Add a sort (price, distance) as another pure transform layered after the filter.

Knowledge check

Q: Why is apply(to:) a pure function rather than a method that reads view state? A pure function of (Filter, [Listing]) is testable, safe to run off the main actor, and can be called once when inputs change. A function that reaches into view state cannot be tested in isolation and tends to get re-run on every render.

Q: What is the concrete cost of filtering inside body? body runs on every render — every keystroke and every unrelated state change — so a filter inlined there re-scans the whole catalog each time. Moving it to a didSet-driven recompute runs it only when the filter actually changes.

Solution.swiftSwift Testing
ConsoleReady · runs in a sandboxed Swift container
Press Run to compile against the hidden test suite.