What is Identifiable, and why does it matter for List/ForEach?
The protocol
Identifiable is a small protocol in the Swift standard library, not SwiftUI:
public protocol Identifiable<ID> {
associatedtype ID: Hashable
var id: ID { get }
}
Just an associated ID: Hashable and a property that returns it — a value that can answer which thing am I, over time.
Swift structs have no built-in answer to that. Two struct Item { var name: String } instances with the same name are indistinguishable — there’s no pointer to compare. Equatable asks do these values currently look the same; Identifiable asks are these the same logical entity, even if the contents differ. A todo item edited from “Buy milk” to “Buy oat milk” is != to its former self, but must stay == in identity, or the UI treats the edit as delete-and-insert.
Why List/ForEach care
SwiftUI diffs a new view tree against the old one rather than mutating views in place. For static views like Text or VStack, that diff is purely structural — same type at the same position means “same view, just update it.”
ForEach/List break that model because their children are data-driven: array count and order can change at runtime. Going from [A, B, C] to [B, C, D] looks, positionally, like “every row’s content changed” rather than “A was removed, D was appended.” That’s the wrong read, and it breaks real things:
- Per-row
@State(cursor position,@FocusState, an expanded toggle) slides onto the wrong item instead of following the item it belongs to. - Insertion/removal transitions cross-fade content instead of animating the actual add/remove.
List(selection:)loses track of which row is selected once the data reorders.
So ForEach/List require explicit identity: a stable id per element. SwiftUI diffs old ids against new ids to compute a minimal edit script, and only ids that survive keep their view’s state. Identifiable is just the standard way to supply that id — it’s why ForEach(items) { ... } works without a separate id: key path.
Three ways people get it wrong
id: \.selfon a merelyHashableelement. Identity becomes tied to value, so two equal-looking rows (both titled “New Item”) collapse to the same identity — editing one can visually jump to the other.id: \.offsetfrom.enumerated(). Identity becomes position, not content. Delete the first row and a focusedTextFieldkeeps its focus but now edits the wrong item, since “row 0” didn’t move with the data.- A computed, non-stable
id, e.g.var id: String { UUID().uuidString }. A fresh id on every read means every diff looks like “all new rows” — no animations, no preserved state, visible flicker.
The fix is a stored, durable id assigned once and never changed — a database primary key, or a UUID() generated in init and stored as let:
struct Item: Identifiable {
let id = UUID() // fixed even as `name` mutates
var name: String
}
Beyond ForEach/List
The same mechanism drives .sheet(item:), .popover(item:), .fullScreenCover(item:), and Table — each uses id to decide whether a new value should update the existing presentation or replace it. Identifiable also gained a primary associated type in Swift 5.7, so you can constrain generic code with any Identifiable<String>.