← All topics/UIKit & SwiftUI internals

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 prepareForReuse help?
  • 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 indexPath after async work without revalidating it against the current data.
  • Prefetching cuts perceived latency, and identity checks after each await keep results on the right cell.
  • Cancel in prepareForReuse so 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.
Configure and prepareForReuse with cancellation
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

  • prepareForReuse runs 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.
  • UICollectionViewDiffableDataSource with snapshots gives you animated inserts, deletes, and reloads from stable Hashable identifiers, with far fewer performBatchUpdates mistakes than manual diffing.
  • SwiftUI ForEach follows the same rule, so the same real-world row must get the same id every time. An id that changes (an array index after a reorder, or a fresh UUID() in body) makes SwiftUI treat it as a new row, rebuild it, and drop its @State.