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.
Identifiableneeds 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.