Staybnb checkpoint — the pathological re-renderswift-6.4/ios-26
Lesson 5 / 6
Unit 27 · Staybnb

Staybnb checkpoint — the pathological re-render

Note

Unit 27 · Staybnb AI checkpoint. A Professional-mode review drill. The model built a results screen that works perfectly with ten listings and janks badly with a thousand. It compiles, it is correct, and it re-renders pathologically. Find out why.

You asked the assistant for a searchable, sorted results list bound to the filter panel. It produced this. It looks clean — declarative, readable, obviously correct. Run it against the full catalog and typing in the search field drops frames. Read it as a reviewer.

The AI-generated code

@MainActor
@Observable
final class ResultsModel {
    var searchText = ""
    var allListings: [Listing] = []
}

struct ResultsView: View {
    @State private var model = ResultsModel()

    // "Computed for convenience" — filter + sort, read from body.
    var visibleListings: [Listing] {
        model.allListings
            .filter { $0.title.localizedCaseInsensitiveContains(model.searchText)
                      || model.searchText.isEmpty }
            .sorted { $0.pricePerNight < $1.pricePerNight }
    }

    var body: some View {
        List {
            ForEach(Array(visibleListings.enumerated()), id: \.offset) { _, listing in
                ListingRow(listing: listing)
            }
        }
        .searchable(text: $model.searchText)
    }
}

Three separate performance sins hide in nine lines. Each one is common, each one compiles, and together they turn a keystroke into a full re-scan-and-rebuild of the list.

Guiding questions

Work these before the explanation.

  1. visibleListings is a computed property read inside body. How many times does it run when the user types one character? What work does each run do?
  2. ForEach(Array(visibleListings.enumerated()), id: \.offset) identifies rows by array index. When the filter changes and the listings reorder, what does SwiftUI think happened to each row?
  3. The .sorted runs on every body evaluation over the whole catalog. On a thousand listings, what is the cost per keystroke?
  4. Where should the filter and sort actually live so they run once per change instead of once per render?

Explanation

Sin 1 — work in body. body re-runs on every render: every keystroke into the search field, and every unrelated state change. visibleListings is read from body, so on each render it filters the entire catalog and sorts it. With a thousand listings that is a full scan plus an O(n log n) sort per character typed — on the main actor, blocking the frame. The computed property reads like a harmless convenience; it is actually the hot loop.

Sin 2 — index as identity. id: \.offset tells SwiftUI each row's identity is its position in the array. When the filter or sort changes the order, row "index 3" is now a different listing, so SwiftUI cannot diff — it tears down and rebuilds rows instead of moving them, throwing away cell state and animating wrong. Listing is already Identifiable with a stable id; using the index throws that away.

Sin 3 — sorting every time. Even setting identity aside, the sort has no reason to re-run unless the data or the sort key changed. Inlined in body, it re-runs regardless.

The fix: compute on change, identify by stable id

Move the filter and sort into the model, recomputed only when their inputs change, and let ForEach use the listing's real id.

@MainActor
@Observable
final class ResultsModel {
    var searchText = "" { didSet { recompute() } }
    var allListings: [Listing] = [] { didSet { recompute() } }
    private(set) var visible: [Listing] = []

    private func recompute() {
        visible = allListings
            .filter { searchText.isEmpty
                      || $0.title.localizedCaseInsensitiveContains(searchText) }
            .sorted { $0.pricePerNight < $1.pricePerNight }
    }
}

struct ResultsView: View {
    @State private var model = ResultsModel()

    var body: some View {
        List(model.visible) { listing in       // uses Listing.id — stable identity
            ListingRow(listing: listing)
        }
        .searchable(text: $model.searchText)
    }
}

Now the filter-and-sort runs in recompute() — once, when searchText or allListings changes — not on every render. body just reads the already-computed visible array. And List(model.visible) uses Listing's own Identifiable id, so when results reorder SwiftUI moves rows and reuses their state instead of rebuilding them. Same output, but a keystroke is now one recompute and a diff, not a re-scan and a teardown.

For a genuinely large catalog you can hop recompute off the main actor and assign back, but the structural win — work out of body, stable identity — comes first and matters most.

Note

Two tells to train your eye on in review: (1) a computed property that does real work (filter, sort, map over a collection) and is read from body; (2) ForEach(..., id: \.offset) or id: \.self on a reorderable collection. Both compile, both look fine at ten items, both fall over at a thousand.

Your turn

The exercise below is the pure filter logic that should live outside body — the extracted, gradable core of the fix. Implement it as a clean filter + map over value types.

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

Knowledge check

Q: Why does a computed property that filters, read from body, hurt performance? Because body re-runs on every render — every keystroke, every unrelated state change — so the filter re-scans the whole collection each time. Moving it to a didSet-driven recompute runs it only when its inputs actually change.

Q: What goes wrong with ForEach(..., id: \.offset) when the list reorders? Identity is tied to array position, so a reorder makes SwiftUI think each row became a different item. It rebuilds rows instead of moving them, discarding cell state. Using the model's stable Identifiable id lets SwiftUI diff and move rows correctly.

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