How does cell reuse work in UITableView, and what bugs can it cause?
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
}- You register a cell class (or nib) with an identifier.
- You ask for a cell. If the queue has one, you get it. If not, UIKit creates one.
- 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.CellRegistrationreplaces 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 = contentCommon follow-up questions
- Difference between
dequeueReusableCell(withIdentifier:)and thefor: indexPathversion? Thefor:version always returns a cell and sizes it correctly. The old one can returnnil. - When is
prepareForReusecalled? Just before the cell is returned from dequeue. - How do you make scrolling smooth? Keep
cellForRowAtfast, downsample images, and use prefetching. - Does SwiftUI
Listreuse cells? Yes.Listis backed by a collection view and reuses rows for you.