Question 5
Table and collection views: reuse, prefetching, and diffing
Why do images or text 'flash' wrong in cells during fast scrolling?
Follow-ups
- How does
prepareForReusehelp? - When would you use
UICollectionViewDiffableDataSource?
Answer outline
Cells are reused: when a cell scrolls off screen it goes back to the pool and can be dequeued for a different index path. If you don't reset async-loaded images, spinners, or selection state, the old content shows until the new load finishes. Worse, a slow load for the old row can land late and overwrite the new one.
prepareForReuse is where you cancel in-flight image loads, clear thumbnails, reset accessibility labels, and remove gesture targets you added dynamically. Prefetching (UICollectionViewDataSourcePrefetching) starts loads for rows about to appear, and didEndDisplaying is where you cancel work for rows that just left. After any await, confirm the cell is still bound to the same item before touching the UI.
A diffable data source (UICollectionViewDiffableDataSource) applies snapshot updates with automatic animations and none of the manual performBatchUpdates bookkeeping. Use it when your model has stable identifiers. You describe sections and items in an NSDiffableDataSourceSnapshot, and the framework computes the diff.
Principles
- Never trust an
indexPathafter async work without revalidating it against the current data. - Prefetching cuts perceived latency, and identity checks after each
awaitkeep results on the right cell. - Cancel in
prepareForReuseso a slow load for the old row can't overwrite the new one. - Diffable snapshots work only when item identifiers are stable and hashable, so identify items by ID rather than by the whole mutable model.
final class PhotoCell: UICollectionViewCell {
private var loadTask: Task<Void, Never>?
private var boundItemId: String?
func configure(with item: Item) {
loadTask?.cancel() // a new configure drops any in-flight work
boundItemId = item.id
imageView.image = placeholder
// capture the id this load belongs to
let itemId = item.id
loadTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
try Task.checkCancellation()
let (data, _) = try await URLSession.shared.data(from: item.thumbURL)
try Task.checkCancellation() // before the expensive decode and UI update
let image = UIImage(data: data)
guard !Task.isCancelled else { return }
// check the id after the await: the cell may have been reused
guard self.boundItemId == itemId else { return }
self.imageView.image = image ?? placeholder
} catch is CancellationError {
return
} catch {
guard !Task.isCancelled, self.boundItemId == itemId else { return }
self.imageView.image = placeholder
}
}
}
override func prepareForReuse() {
super.prepareForReuse()
// reset state so the next row starts clean
loadTask?.cancel()
loadTask = nil
boundItemId = nil
imageView.image = nil
}
}
Follow-up angles
prepareForReuseruns just before a recycled cell is handed back to you, so resetting there guarantees every row starts clean no matter what the previous row left behind.UICollectionViewDiffableDataSourcewith snapshots gives you animated inserts, deletes, and reloads from stableHashableidentifiers, with far fewerperformBatchUpdatesmistakes than manual diffing.- SwiftUI
ForEachfollows the same rule, so the same real-world row must get the sameidevery time. Anidthat changes (an array index after a reorder, or a freshUUID()inbody) makes SwiftUI treat it as a new row, rebuild it, and drop its@State.



