An item's title changed but its cell still shows the old text after applying a new snapshot. Why, and what is the difference between reconfigureItems and reloadItems?

7.12.1 · L3 · Interview

Predict first

What do a same-id snapshot, reconfigure and reload each trigger?

import UIKit

struct Item: Hashable, Sendable {
    let id: Int
    var title: String
    static func == (l: Item, r: Item) -> Bool { l.id == r.id }
    func hash(into h: inout Hasher) { h.combine(id) }
}
var events: [String] = []
final class Cell: UICollectionViewListCell {
    override func prepareForReuse() { super.prepareForReuse(); events.append("prepareForReuse") }
}
let layout = UICollectionViewCompositionalLayout.list(using: UICollectionLayoutListConfiguration(appearance: .plain))
let cv = UICollectionView(frame: CGRect(x: 0, y: 0, width: 320, height: 600), collectionViewLayout: layout)
let reg = UICollectionView.CellRegistration<Cell, Item> { cell, _, item in
    events.append("configure \(item.title)")
    var c = cell.defaultContentConfiguration()
    c.text = item.title
    cell.contentConfiguration = c
}
let ds = UICollectionViewDiffableDataSource<Int, Item>(collectionView: cv) { cv, ip, item in
    events.append("provider \(item.id)")
    return cv.dequeueConfiguredReusableCell(using: reg, for: ip, item: item)
}
let w = UIWindow(frame: cv.frame)
w.addSubview(cv)
w.makeKeyAndVisible()
var s = NSDiffableDataSourceSnapshot<Int, Item>()
s.appendSections([0])
s.appendItems([Item(id: 1, title: "a"), Item(id: 2, title: "b")])
ds.apply(s, animatingDifferences: false)
cv.layoutIfNeeded()
events = []
var s2 = NSDiffableDataSourceSnapshot<Int, Item>()
s2.appendSections([0])
s2.appendItems([Item(id: 1, title: "NEW"), Item(id: 2, title: "b")])
ds.apply(s2, animatingDifferences: false)
cv.layoutIfNeeded()
print("same id, new title:", events, ds.itemIdentifier(for: IndexPath(item: 0, section: 0))!.title)
events = []
var r = ds.snapshot()
r.reconfigureItems([Item(id: 2, title: "")])
ds.apply(r, animatingDifferences: false)
cv.layoutIfNeeded()
print("reconfigure:", events)
events = []
var l = ds.snapshot()
l.reloadItems([Item(id: 2, title: "")])
ds.apply(l, animatingDifferences: false)
cv.layoutIfNeeded()
print("reload:", events)
  • same id, new title: [] NEW / reconfigure: [provider 2, configure b] / reload: [provider 2, prepareForReuse, configure b]
  • same id, new title: [configure NEW] / reconfigure and reload identical
  • same id, new title: [] a / …

Mental model. Identity is a name tag: if the tag is unchanged the usher assumes the same guest, so a new outfit goes unnoticed until you ask the usher to take another look (reconfigure) or to seat them again from scratch (reload).

A diffable data source compares snapshots by identity (Hashable equality), not by content. When the item's == and hash use only its id, a new snapshot containing the same id with a new title is "no change": the check applied one and the cell provider ran zero times, while itemIdentifier(for:) already returned the new title, so the model and the visible cell disagree. This is Q247's identity rule showing up as a stale UI. Two ways to fix it: put a version or timestamp in the identity (then the edit looks like delete plus insert, with animation and state loss), or keep identity stable and tell the snapshot which items to refresh.

The refresh calls differ in cost. reconfigureItems (iOS 15) asks the provider and the registration for a new configuration and applies it to the existing cell; in the check the log was provider, configure, with no prepareForReuse. reloadItems dequeues a cell as if it were new, so the log also contained prepareForReuse before configure, and the cell may be a different instance with its transient state (an in-progress swipe, a focused text field) reset; it also changes the item's size lookup. Selection survived the reload in this check. Prefer reconfigureItems unless the cell type changes; the cell provider must read the latest data by identifier from your model rather than trusting the item value held by the snapshot, because the snapshot stores whichever value it received first. Apply snapshots from one queue and keep the work in the registration light.

Equal ids trigger nothing; reconfigure skips prepareForReuse and reload includes it.

Common traps

  • Mutable fields in == and hash. It makes edits look like delete and insert; the opposite mistake (identity only) leaves cells stale.
  • Trusting the snapshot's item value in the provider. Look the live model up by id.

They might follow up with

  • Does reconfigureItems animate? The configuration change itself can animate through the content configuration; the layout may self-size again.
  • When is reloadItems right? When the cell class or its heavy state must be rebuilt.

Related questions

  • 7.12.2 How do you build an expandable outline with NSDiffableDataSourceSectionSnapshot?
  • 7.5.2 How does a diffable data source work, and what does it require of your item identifiers?
  • 7.5.3 How does cell reuse work, and what are the classic reuse bugs?

How do you build an expandable outline with… →