Home

Identity and lifetime

How SwiftUI decides two views are the same view

Your structs are recreated constantly and SwiftUI’s storage is not. Between the two sits one question, asked on every update: is this the same view I saw last time, or a different one?

The answer decides everything that feels magical or broken. Same view: @State survives, changes animate, a text field keeps its cursor. Different view: state is thrown away and rebuilt from its initial value, the old view transitions out and the new one in, the cursor is gone.

There are two ways SwiftUI answers it.

Structural identity

By default, identity is position in the view tree. Not the type, not the contents — the place.

VStack {
    Text("Header")
    ProfileCard(user: user)
}

ProfileCard is “the second child of that VStack”. Next update, whatever appears in that slot is treated as the same view, and its storage is reused. The user changing does not create a new identity — it is the same card showing different data, which is exactly what you want.

The trap is that if creates two different slots:

if isEditing {
    NameField(name: $name)
} else {
    NameField(name: $name)
}

Those look identical and are not. The builder produced _ConditionalContent, the true branch and the false branch are separate positions, and flipping isEditing destroys one NameField and creates another. Any @State inside it resets, and the keyboard dismisses.

The fix is to have one view whose input changes, rather than two views:

NameField(name: $name)
    .disabled(!isEditing)

Warning

This is the single most common cause of “my @State keeps resetting” and “the keyboard closes when I type”. Before reaching for anything clever, look for an if that has two branches rendering the same thing in different configurations.

Explicit identity

The other way is to say it outright, with id. This is required wherever SwiftUI cannot work position out for itself — chiefly in collections:

List(orders) { order in
    OrderRow(order: order)
}

List needs Order to be Identifiable because rows get inserted, deleted and reordered, and position is meaningless under those operations. The row for order #17 must stay the row for order #17 even when three rows above it disappear.

Get the id wrong and the symptoms are memorable. Use the array index and deleting the first row makes every row below it appear to change contents — with an animation of each row’s text crossfading, because SwiftUI believes row 0 genuinely became a different order. Use a UUID() generated in body and every row is new on every update, so the list rebuilds constantly and scrolling stutters.

struct Order: Identifiable {
    let id: UUID          // stored, stable, from the model
    let title: String
}

The rule: an id must come from the data and survive as long as the thing it names.

.id() as a deliberate reset

.id() sets identity by hand, and its main use is not lists — it is forcing a view to be replaced:

ArticleView(article: article)
    .id(article.id)

Without the .id, navigating from one article to another reuses the same ArticleView storage, so its @State — the scroll position, the “show translation” toggle — carries across from the previous article. With it, each article gets a fresh view, and everything resets.

That is a legitimate tool, and it is also a blunt one. It throws away all the storage under that view and rebuilds the subtree, so used on something large or applied on every update it becomes the performance problem. Reach for it when a reset is genuinely what you mean.

Lifetime, and why onAppear is not viewDidAppear

A view’s lifetime is the lifetime of its identity, not of the struct. The struct is created and destroyed constantly and none of that is observable.

onAppear runs when a view with that identity joins the tree; onDisappear when it leaves. Neither is tied to visibility the way the UIKit names suggest — a row scrolled off screen in a List may or may not disappear, and a view inside a TabView may appear long before its tab is selected.

For work that should follow the view’s life, .task is almost always the better tool: it starts with the view and its Task is cancelled automatically when identity goes away.

.task(id: article.id) {
    await viewModel.load(article.id)
}

The id: there restarts the task when the article changes, which is the behaviour you want and is tedious to write correctly by hand.