Staybnb checkpoint — the pathological re-render
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.
visibleListingsis a computed property read insidebody. How many times does it run when the user types one character? What work does each run do?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?- The
.sortedruns on everybodyevaluation over the whole catalog. On a thousand listings, what is the cost per keystroke? - 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.
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.
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.