iosNudge

How does cell reuse work in UITableView, and what bugs can it cause?

EasyAsked oftenUIKitPerformance 2 min read

TL;DR

A table view only creates enough cells to fill the screen, plus a few more. When a cell scrolls off screen, it goes into a reuse queue. dequeueReusableCell gives you an old cell to fill with new data. The main bug: old content shows up in a new row because you did not reset it.

How it works

override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(ContactCell.self, forCellReuseIdentifier: "ContactCell")
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "ContactCell", for: indexPath) as! ContactCell
    cell.configure(with: contacts[indexPath.row])
    return cell
}
  1. You register a cell class (or nib) with an identifier.
  2. You ask for a cell. If the queue has one, you get it. If not, UIKit creates one.
  3. You configure it. When it leaves the screen, it goes back to the queue.

With 1,000 rows you may only have about 12 cell objects. This saves memory and time.

Bug 1: stale content

func configure(with contact: Contact) {
    nameLabel.text = contact.name
    if contact.isFavorite {
        starView.isHidden = false
    }
    // Bug: never sets isHidden = true. Old stars stay on other rows.
}

Fix: set every property in every branch. Or reset in prepareForReuse.

override func prepareForReuse() {
    super.prepareForReuse()
    starView.isHidden = true
    avatarView.image = nil
    imageTask?.cancel()
}

Keep prepareForReuse cheap. Reset view state only. Do not put content setup there.

Bug 2: wrong image after async load

The cell starts a download. The user scrolls. The same cell is now used for another row. The old download finishes and sets the wrong image.

final class ContactCell: UITableViewCell {
    private var imageTask: Task<Void, Never>?
    private var currentID: Contact.ID?

    func configure(with contact: Contact, loader: ImageLoader) {
        currentID = contact.id
        nameLabel.text = contact.name
        avatarView.image = nil
        imageTask?.cancel()
        imageTask = Task { [weak self] in
            let image = await loader.image(for: contact.avatarURL)
            guard let self, self.currentID == contact.id else { return }
            self.avatarView.image = image
        }
    }
}

Two protections: cancel the old task, and check the identity before you set the image.

Bug 3: storing state in the cell

Cells are temporary. If a switch in a cell is toggled, save the value in your model, not only in the cell. Otherwise the value is lost or moves to another row when the cell is reused.

Modern APIs

  • UIListContentConfiguration (iOS 14) gives standard cell layouts and resets cleanly.
  • For collection views, UICollectionView.CellRegistration replaces string identifiers.
  • UIHostingConfiguration (iOS 16) lets you write the cell content in SwiftUI.
var content = cell.defaultContentConfiguration()
content.text = contact.name
content.secondaryText = contact.phone
cell.contentConfiguration = content

Common follow-up questions

  • Difference between dequeueReusableCell(withIdentifier:) and the for: indexPath version? The for: version always returns a cell and sizes it correctly. The old one can return nil.
  • When is prepareForReuse called? Just before the cell is returned from dequeue.
  • How do you make scrolling smooth? Keep cellForRowAt fast, downsample images, and use prefetching.
  • Does SwiftUI List reuse cells? Yes. List is backed by a collection view and reuses rows for you.