Staybnb M2 — filtering and search
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.
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.
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.)
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.
Checkpoint: your app should now…
- Have a
Filtervalue 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 insidebody. - Filter by category from the explore row, and save/unsave a listing via an
@ObservableWishlistin 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.