iOS – Lists, Grids and Keeping Them Fast

August 1, 20268 min readUpdated 9/18/2026

Most of what a mobile app does is show a list of things. It is also where SwiftUI performance is won or lost, and where the difference between three containers that look interchangeable turns out to matter a great deal.

This lesson is List against LazyVStack against LazyVGrid, what identity is really doing, and what the framework gives you instead of React's memo — including the part you still have to do yourself.

The one that is not lazy

Start with the mistake, because it is easy to make and invisible until it is not:

ScrollView {
    VStack {
        ForEach(orders) { order in
            OrderCard(order: order)
        }
    }
}

A VStack builds every child before showing you the first one. With a dozen orders nothing is wrong. With five hundred, opening the screen constructs five hundred views, runs five hundred bodies, and holds all of them in memory — and because it happens on the main thread, the app is unresponsive while it does.

The fix is one word:

        ScrollView {
            LazyVStack(spacing: Spacing.md) {

"Lazy" means children are built as they approach the viewport and discarded as they leave. Reaching for the eager container in a scrolling list is the single most common SwiftUI performance mistake, and the habit is worth forming before you have a list long enough to notice.

Grids

A grid is the same idea with columns. The demo app's menu is two columns of product cards:

    private let columns = [
        GridItem(.flexible(), spacing: Spacing.md),
        GridItem(.flexible(), spacing: Spacing.md),
    ]

Three kinds of GridItem cover everything. .flexible() shares the space equally, which is what a fixed number of columns wants. .adaptive(minimum:) fits as many as it can, so the column count changes with the screen — the right choice for a photo grid. .fixed() pins a width, which is occasionally what a sidebar needs and usually not what you want.

                LazyVGrid(columns: columns, spacing: Spacing.md) {
                    ForEach(visible) { product in
                        ProductCardView(product: product) { productBeingBuilt = product }
                    }
                }

Note the closure passed to each card. That is how a row reports a tap upward without knowing what happens next — the card's job is to draw a product and say when it was pressed.

List, and when to prefer it

List is lazy too, and it brings a great deal the others do not: swipe actions, selection, section headers, the standard row insets and separators, pull-to-refresh, and the platform's own scrolling behaviour. If you are building something that should look like Settings or Mail, use it — reimplementing swipe-to-delete on a LazyVStack is a bad afternoon.

The reason the demo app does not is styling. Its rows are brand-coloured cards on a cream background, and stripping List back to that means fighting its insets, separators and backgrounds on every row. When the design is a feed of cards rather than a table of rows, LazyVStack starts from nothing and stays out of the way.

Identity, and why a bad id is a rendering bug

ForEach needs to tell items apart across updates — to know that this row is the same row it drew before, moved, rather than a different one. That is identity, and it drives animation, state retention and scroll position.

The clean way is Identifiable:

struct Product: Codable, Identifiable, Hashable {
    let id: UUIDString
    let name: String

Then ForEach(products) needs no key path at all. Two failure modes are worth knowing.

id: \.self on a value that can repeat. For a [String] with two identical entries, SwiftUI sees one identity and the list behaves bizarrely — rows sharing state, animations jumping. It is fine for a genuinely unique set and a trap everywhere else.

An id derived from the index. Insert at the top and every item's identity shifts by one, so the framework believes every row changed. Everything re-renders, state moves between rows, and the insertion animates as a total replacement.

Enumerating without losing identity

Sometimes you need the index — to draw a divider between items rather than before the first one. The idiom keeps the element's own id:

                ForEach(Array(cart.items.enumerated()), id: \.element.id) { index, item in
                    if index > 0 { HairlineDivider(isSpaced: false) }

Identity comes from element.id, the index is only used for layout. Using id: \.offset there would be the index mistake with extra steps.

What replaced React.memo

If you have come from React, list performance means memo on every row and useCallback on every handler, because without both, a state change in the parent re-renders every row on screen.

SwiftUI needs neither. A View is a value, and the framework compares the old and new values structurally before deciding to redraw — so a card whose product is unchanged is not re-rendered even though its parent was. The memoisation is the framework's job, not the author's.

What does transfer is the obligation to keep the value cheap to compare. Three rules follow:

  • Pass the data, not the store. A row that takes the whole cart depends on the whole cart; a row that takes one line depends on one line.
  • Keep rows small and specific. ProductCardView takes a Product and a closure, and that is the entire surface.
  • Do the work outside the body. Sorting or filtering inside a row's body happens on every redraw, of every row.

Filtering, and where it belongs

            let visible = catalogue.products.filter(filter.matches)

Computed once, above the ForEach, rather than inside it. For a dozen products this is free; the habit matters when the collection is large or the predicate is not trivial. If the filtering were genuinely expensive, it would move into the view model as a stored value updated when its inputs change — but reach for that only once you have measured, because a cache that can go stale is a worse bug than a slow list.

Pull to refresh

There is no reload button on a phone, so a list that fetches once and never again is a list the customer cannot fix when something looks stale. One modifier:

    private func list(orders: [Order], model: OrdersViewModel) -> some View {
        ScrollView {
            LazyVStack(spacing: Spacing.md) {
                if let email = auth.user?.email {
                    Text(email)
                        .textStyle(.caption, tone: .muted)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }

                ForEach(orders) { order in
                    Button {
                        router.push(.order(id: order.id))
                    } label: {
                        orderCard(order)
                    }
                    .buttonStyle(.plain)
                    .accessibilityLabel(accessibilityLabel(for: order))
                }
            }
            .padding(Spacing.lg)
        }
        .background(Theme.colors.background)
        .refreshable { await model.load(showsLoadingState: false) }
    }

Two details make it behave. .refreshable keeps the system spinner on screen for exactly as long as the await takes, which is why the view model exposes an awaitable reload rather than a fire-and-forget one — hand it something that returns immediately and the spinner flashes and vanishes.

And showsLoadingState: false: replacing the list with a full-screen spinner when the customer has just dragged it down makes the content disappear under their finger. The refresh control is already the progress indicator.

A row is a button, not a tappable stack

Notice how the whole card is wrapped in a Button with .buttonStyle(.plain). That is deliberate and it is an accessibility decision as much as a visual one. A .onTapGesture on a VStack also works and carries no semantics at all: VoiceOver does not announce it as a button, the system does not give it the press feedback, and it does not respond to switch control or a keyboard. .plain strips the default styling while keeping every one of those behaviours.

What a row should actually contain

Rows tend to grow. Keeping one readable is mostly a matter of splitting it into named pieces and being deliberate about text:

    private var details: some View {
        VStack(alignment: .leading, spacing: Spacing.xs) {
            Text(product.name)
                .textStyle(.subheading)
                .lineLimit(1)

            Text(product.description)
                .textStyle(.caption, tone: .muted)
                .lineLimit(2, reservesSpace: true)

            HStack {
                priceLabel
                Spacer(minLength: Spacing.sm)
                Text(product.type == .pizza ? "Build it →" : "Add →")
                    .font(.system(size: FontSize.sm, weight: .semibold))
                    .foregroundStyle(Theme.colors.primary)
            }
            .padding(.top, Spacing.xs)
        }
        .padding(Spacing.md)
    }

reservesSpace: true is the line that makes a grid line up. Without it a one-line description makes its card shorter than the one beside it, and the two columns drift apart down the page. It is the native answer to a fixed-height clamp in CSS, and the sort of thing that only shows up once your content is real rather than lorem ipsum.

Scrolling, targeted

Two modifiers worth knowing before you need them. ScrollViewReader gives a scrollTo(id) for jumping to an item — the standard answer to "scroll to the bottom after sending a message". And .scrollDismissesKeyboard(.interactively) lets a drag dismiss the keyboard, which on a form is the difference between a usable screen and one where the submit button is unreachable.

Measuring rather than guessing

Every rule above is a default worth following, not a substitute for looking. Three things to reach for, in order.

Run in Release. Debug builds are unoptimised and SwiftUI in particular is much slower under them; a great many "performance problems" are the debugger. Instruments, and specifically the SwiftUI template, shows which bodies are running and how often — usually the surprise is not that a body is slow but that it runs far more often than expected. And a real device, because your Mac is several times faster than the phone your customer has.

A note on very large lists

If a list is thousands of rows, the container stops being the bottleneck and the data does. Two things start to matter: fetching a page at a time rather than everything, and keeping each row's model small so that holding a screenful is cheap. The demo app's order history fetches twenty and has no pagination UI yet — a gap its README states rather than hides, because "there is no second page" is a product decision and "the second page silently never loads" is a bug.

Empty and failed are list states too

A list has at least four states, and the one that ships broken is always the same. Loading, a list with content, a list with nothing in it, and a request that failed. Rendering a spinner and forgetting the fourth gives you a screen that spins forever the first time the backend is down — and because it only happens when something else is already wrong, it is rarely caught before release.

An empty result deserves its own treatment rather than being drawn as a list of zero rows. "No orders yet" with a button into the menu is a destination; a blank screen is a dead end. Lesson 11 makes this a type so the framework stops you skipping a case, which is the only way it reliably stops happening.

Next

Lists lead to detail screens, and detail screens lead to forms. The next lesson is text input — the keyboard as part of your UI, the autocapitalisation default that breaks every email field, and validation rules that live outside the view and can be tested in microseconds.