Identifiable made easy

Learn why SwiftUI lists need stable identity, how Identifiable gives it to them, and the identity mistakes that make rows animate wrong or keep the wrong state.

Introduction

In the previous posts we looked at @State, which lets a view own its data, and @Binding, which lets a child view share it.

Both were about a single value. Real apps are full of collections: a list of todos, a feed, a set of search results.

The moment you render a collection, SwiftUI needs to answer a new question, and it needs an answer for every row: is this the same row I had a moment ago?

Identifiable is how you answer it.

Why identity matters

Here’s the mental model that makes everything else click.

A list is not a redraw. It’s a diff.

When your array changes, SwiftUI compares the rows it had against the rows you’re giving it now. For each one it asks whether it already knows this row. If it does, it keeps that row and animates the change. If it doesn’t, it builds a new row from scratch.

Identity is the only information it has to make that call.

Get identity right and everything downstream works: animations move the right row, per-row state stays with the right item, deletes slide out from the right place.

Get it wrong and all of those break at once, in ways that look like unrelated bugs.

Making a type Identifiable

The protocol asks for one thing: an id that is stable for the lifetime of the item.

struct TodoItem: Identifiable {
    let id = UUID()   // born with the item, never derived from its data
    var title: String
    var notes: String
}

Two details in that one line matter more than they look.

UUID() runs once, when the item is created. The id is not calculated from the title or the position, so nothing the user types can change it.

And it’s a let. Identity that can be reassigned is not identity, so let the type enforce it.

Building the list

With that in place, ForEach finds the id on its own and you get delete and reorder almost for free.

struct TodoListView: View {
    @State private var todos = [
        TodoItem(title: "Buy milk", notes: "The oat one"),
        TodoItem(title: "Buy a book", notes: "Horror, 2025")
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach(todos) { todo in   // uses Identifiable's id automatically
                    TodoRow(todo: todo)
                }
                .onDelete { offsets in
                    todos.remove(atOffsets: offsets)
                }
                .onMove { source, destination in
                    todos.move(fromOffsets: source, toOffset: destination)
                }
            }
            .toolbar { EditButton() }   // reordering needs edit mode
        }
    }
}

Notice where the modifiers live. onDelete and onMove attach to the ForEach, not to the List. They are about the rows the loop produced, not about the container around them.

Deleting doesn’t delete anything

This one surprises people, and it’s worth saying plainly.

Swiping a row does not remove your data. It plays a removal animation and hands you an event.

Your array is the source of truth, and the view only ever draws the array. So if you don’t remove the item yourself inside that closure, the next rebuild puts the row straight back, because as far as your data is concerned nothing happened.

.onDelete { offsets in
    todos.remove(atOffsets: offsets)   // required, not optional
}

Same idea for reordering. The gesture reports where the row went, you apply it to the array.

Common pitfalls

Using the position as the id

This is the big one, and it compiles perfectly.

ForEach(Array(todos.enumerated()), id: \.offset) { index, todo in  // ❌
    TodoRow(todo: todo)
}

Positions are not identity. They change whenever the collection changes.

To see it break, give each row some state of its own:

struct TodoRow: View {
    @State private var isExpanded = false
    let todo: TodoItem

    var body: some View {
        VStack(alignment: .leading) {
            Text(todo.title).font(.headline)
            if isExpanded { Text(todo.notes) }
        }
        // tap to toggle isExpanded
    }
}

Now expand the third row, then delete the second one.

The third row collapses and a different row appears expanded. Deleting shifted every position below it, so the id that used to mean “third item” now belongs to a different todo. The row’s state stayed with the number instead of the item.

Using the data as the id

ForEach(todos, id: \.title) { todo in ... }  // ❌

Two todos called “Buy milk” are now the same row as far as SwiftUI is concerned. And renaming a todo destroys the old row and creates a new one, so it can’t animate the change and any state in that row is lost.

Letting a button eat the whole row

Not strictly an identity problem, but you’ll hit it in the same afternoon.

Put one button in a list row and SwiftUI assumes you mean the normal iOS pattern of “tapping this row does the thing”, so it stretches the tap target across the entire row. If your button is a control inside the row rather than the row’s action, say so:

Button { isExpanded.toggle() } label: {
    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
}
.buttonStyle(.borderless)

A bare if inside the loop

Lists build rows lazily, which only works if each element is worth a predictable number of views. A bare condition makes a row worth zero views or one:

ForEach(todos) { todo in
    if todo.isDone { Text(todo.title) }   // ❌ zero or one
}

Wrap the condition in a stack so the count per row stays fixed:

ForEach(todos) { todo in
    VStack {
        if todo.isDone { Text(todo.title) }
    }
}

How to spot an identity bug

Identity bugs have a signature, and it’s a timing one.

They only show up after the collection changes. Insert, delete or move something, and suddenly state or an animation is attached to the wrong item.

A layout bug looks wrong immediately and looks wrong every time. If your list looked fine until you deleted a row, look at the id first.

Recap

  • A list is a diff, not a redraw, and identity is how SwiftUI matches old rows to new ones.
  • Identifiable needs an id that is created with the item and never changes.
  • Never use the position as the id, and never use data the user can edit.
  • Deleting and reordering are events. Your array is the truth, so you apply them yourself.
  • If a bug only appears after the collection changes, suspect identity.

Enjoyed this post?

Subscribe to get new articles delivered to your inbox.

No spam. Unsubscribe anytime.