Tutorials › Launch Lab Notes › Chapter 5

Which View Is Which: Identity, and Why Your State Ends Up on the Wrong Row

LabChapter 5 of the Launch Lab Notes26 minAugust 19, 2026Intermediate

Four chapters of motion, and every one of them quietly depended on something none of them mentioned.

An animation continues instead of restarting because SwiftUI decided the view before and the view after were the same view. A transition fires because it decided they were different views. A matched pair links up because two frames belong to one identity. Get that decision wrong and nothing in the previous four chapters works, in ways that look like animation bugs and are not.

This chapter is about the decision itself.

Six frames of two lists over the same array as a row is inserted and removed: the top list keeps every name on its own colour, while the bottom list ends up with names sitting on colours that belong to other rows.

The setup, and why it is readable

Two lists. The same array. The only difference is one argument.

// Identity travels with the data.
ForEach(ships) { ship in
  IdentityRow(ship: ship, stamper: stableStamper)
}
 
// Identity belongs to the slot.
ForEach(ships.indices, id: \.self) { index in
  IdentityRow(ship: ships[index], stamper: indexStamper)
}

Each row displays two things that come from opposite places:

/// `ship` is *data* — re-read on every render. `capturedTint` and `stamp` are
/// *state*, written once when this view identity first appeared and never
/// touched again. When the name and the colour disagree, you are looking at a
/// view that SwiftUI kept and re-pointed at a different model.
private struct IdentityRow: View {
  let ship: Ship                        // data
 
  @State private var capturedTint: Color?   // state
  @State private var stamp: Int?
 
  var body: some View {
    HStack { Text(ship.name); Spacer(); Text("#\(stamp ?? 0)") }
      .background(capturedTint ?? Lab.dim)
      .onAppear {
        guard capturedTint == nil else { return }
        capturedTint = ship.tint
        stamp = stamper.take()
      }
  }
}

The name is read fresh every render. The colour and the number are written once, the first time that view identity appears, and never again. So a row where the name and the colour disagree is a row whose view outlived the data it was created for.

At rest, both lists agree completely:

Both lists showing the same three rows — Aurora Notes on green, Tidepool on blue, Kiln on amber — with matching serial numbers and no warnings.

Insert one row at the top

After inserting Nimbus at the top: the id-keyed list shows Nimbus on pink, Aurora on green, Tidepool on blue and Kiln on amber. The index-keyed list shows Nimbus on green, Aurora on blue and Tidepool on amber, each with a warning triangle, and Kiln on amber.

Top list, keyed by Ship.id. Nimbus arrives on its own pink with a brand-new serial. Aurora, Tidepool and Kiln keep their colours and their original numbers. Three views survived; one was created.

Bottom list, keyed by index. Three warning triangles. Nimbus is sitting on Aurora's green. Aurora is on Tidepool's blue. Tidepool is on Kiln's amber. Every row is wearing the state of the row that used to be one position further up.

Nothing moved in the second list. The data slid underneath views that stayed exactly where they were.

Index 0 was already on screen and is still index 0, so SwiftUI kept that view — and kept its @State. It simply pointed it at a different Ship. Same for 1 and 2. Only index 3 did not exist before, so only index 3 is genuinely new.

The list is not "re-ordering badly". It is doing precisely what you asked: you told it that identity means position.

Read the serial numbers on the bottom list and the story is complete: #3, #2, #1, then #4. The first three are the original three views, still in their original slots. The #4 is the one new identity, and it landed at the bottom — as far as possible from where the new data actually went.

Mini-exercise

Predict what happens if you remove the first row instead of inserting one, then run it. In the id-keyed list one view is destroyed and two survive. In the index-keyed list the last slot is destroyed and every surviving row inherits data from the row below it. Same mechanism, opposite direction, equally wrong.

The two kinds of identity

SwiftUI has exactly two ways to tell views apart.

Structural identity is a view's position in the view tree. You never write it down; it comes from the shape of your code. This is why the two branches of an if are different identities:

if isCompact {
  ShipCard(ship: ship)     // one identity
} else {
  ShipCard(ship: ship)     // a different identity
}

Two ShipCards of the same type with the same data, and SwiftUI considers them unrelated. Flip isCompact and the first is destroyed, its @State discarded, and the second is created from scratch — with a transition, because to SwiftUI one view left and another arrived. The fix, when you want continuity, is one view with a varying modifier rather than two views in two branches.

Explicit identity is when you say it out loud — the id: in a ForEach, or the .id(_:) modifier. That is what this bench manipulates.

@State belongs to the identity, not to the struct

A View struct is created and thrown away constantly — many times per second during an animation. The @State it declares does not live in the struct; it lives in storage SwiftUI keeps alongside that view's identity.

That is the whole reason this chapter matters. "Which identity" decides where your state lives, so getting it wrong does not corrupt your data — it hands the right state to the wrong view.

How to key a ForEach, in order of preference

Conform to Identifiable. One requirement — an id property that is Hashable — and ForEach(ships) needs no id: argument at all. This is the default you should reach for.

struct Ship: Identifiable, Equatable {
  let id: String
  let name: String
  let tint: Color
}

Pass a stable key path when you cannot conform the type: ForEach(ships, id: \.registration). Same guarantee, spelled at the call site.

id: \.self works if the element itself is Hashable — fine for a set of enum cases or a fixed list of strings. It has a sharp edge: two equal elements are one identity. A list with a duplicate silently renders fewer rows than you have data, which is a bewildering bug the first time you meet it.

id: \.self over indices is the one in the bottom panel, and it is the one to be suspicious of. It is not always wrong — for a collection that never inserts, removes or reorders, position is a stable identity, and indexing is sometimes the only way to get a Binding. But the moment the collection can change shape, you have told SwiftUI that the fourth row is always the fourth row, and it will believe you.

The one that is always wrong: a fresh UUID() per render
ForEach(ships, id: \.self.freshID)   // computed as UUID() each time

Every render produces new ids, so every render destroys every view and builds new ones. @State resets constantly, transitions fire on every update, animations restart from zero, and scroll position jumps. It looks like a rendering bug and it is an identity bug.

UUID() is a fine id — assigned once, when the model is created, and stored. It is never a fine id computed in a view's body.

.id() as a deliberate reset switch

Everything above treats an identity change as a hazard. It is also a tool.

DraftForm(release: release)
  .id(release.id)

Now switching releases genuinely replaces the form: every @State inside it — half-typed fields, expanded sections, scroll position — is discarded, because that is a different view now. Without the .id(), the same form view would persist and quietly show the previous release's half-finished edits.

That is the honest summary of the modifier: .id() means "when this value changes, throw this view away and build a new one." It is the right answer for a reset, and a performance disaster if the value changes often.

What this explains about the last four chapters

Challenges

  1. Reorder instead of inserting. Swap the first two ships. The id-keyed list moves two rows; the index-keyed list moves nothing and swaps two names. Watch which one you would rather debug at 2am.

  2. Give the rows a text field. Add a TextField bound to per-row @State to IdentityRow, type something into the second row, then insert at the top. In one list your typing follows the ship; in the other it stays in the slot.

  3. Break it with a duplicate. Add a second ship with the same id to the stable list. Note that the failure is not a crash or a warning — it is a row that does not appear.

  4. Use .id() on purpose. Wrap the index-keyed list in .id(ships.count) so the whole list is rebuilt whenever the count changes. The mismatch disappears — and so does every animation, because now nothing survives to animate. That trade is worth feeling.

  5. Find one in your own code. Search for indices, enumerated(), and id: \.self in a project you already ship. Not all of them are bugs. Work out, for each, whether that collection can ever change shape.

Key Points

Five chapters in, the Lab has covered how motion works and what it is built on. The next bench turns to the other half of every animated interface — the one that decides when things change rather than how: state, bindings, and the observation system that decides which views get to hear about a change at all.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 4: Shapes Have to Be RedrawnCh 6: Who Had to Run Again→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access