iosNudge

What is a diffable data source and why use it?

MediumAsked oftenUIKit 2 min read

TL;DR

With the old data source you call insertRows, deleteRows and reloadData yourself. One wrong index and the app crashes. A diffable data source works with snapshots. You describe the full new state. UIKit finds the differences and animates them. Items are identified by Hashable identifiers.

The basic setup

enum Section { case main }

final class ContactsViewController: UIViewController {
    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<Section, Contact.ID>!
    private var contactsByID: [Contact.ID: Contact] = [:]

    private func configureDataSource() {
        let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Contact.ID> {
            [weak self] cell, _, id in
            guard let contact = self?.contactsByID[id] else { return }
            var content = cell.defaultContentConfiguration()
            content.text = contact.name
            cell.contentConfiguration = content
        }

        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) {
            collectionView, indexPath, id in
            collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)
        }
    }

    func show(_ contacts: [Contact]) {
        contactsByID = Dictionary(uniqueKeysWithValues: contacts.map { ($0.id, $0) })
        var snapshot = NSDiffableDataSourceSnapshot<Section, Contact.ID>()
        snapshot.appendSections([.main])
        snapshot.appendItems(contacts.map(\.id))
        dataSource.apply(snapshot, animatingDifferences: true)
    }
}

Use IDs, not full models

Apple recommends identifiers as items, not whole structs. If you use the full struct, any changed property makes a "new" item. UIKit then deletes and inserts it instead of updating it. You lose the cell state and get odd animations.

With IDs, you tell UIKit which items changed:

var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([changedID]) // iOS 15+
dataSource.apply(snapshot)
MethodWhat it does
reconfigureItemsUpdates the existing cell. Fast. Keeps the cell.
reloadItemsThrows the cell away and dequeues a new one. Slower.

Prefer reconfigureItems for content changes.

Rules that cause crashes

  • Identifiers must be unique in the whole snapshot. Duplicates crash or log errors.
  • Do not mix performBatchUpdates or reloadData with a diffable data source. Use snapshots only.
  • apply can run on a background queue, but always call it from the same queue. The main queue is the simple choice.

Section snapshots

For expandable lists (outline style), use NSDiffableDataSourceSectionSnapshot. It supports parent and child items.

var section = NSDiffableDataSourceSectionSnapshot<Contact.ID>()
section.append([groupID])
section.append(childIDs, to: groupID)
dataSource.apply(section, to: .main)

Pair it with compositional layout

UICollectionViewCompositionalLayout (iOS 13) builds layouts from items, groups and sections. For simple lists, use the list configuration:

let config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
let layout = UICollectionViewCompositionalLayout.list(using: config)

Why interviewers like this topic

It shows you know the classic crash: "Invalid update: invalid number of items in section". A diffable data source removes this whole class of bugs, because there is one source of truth: the snapshot.

Common follow-up questions

  • Does it work with UITableView? Yes, UITableViewDiffableDataSource has the same API.
  • How fast is the diff? It is linear time, O(n). Fine for thousands of items.
  • How do you animate without diffing? applySnapshotUsingReloadData replaces everything with no animation.
  • How do you support swipe to delete or reordering? Subclass the data source, or use reorderingHandlers and list cell swipe actions.
  • What is the SwiftUI version? List and ForEach with Identifiable data do the diffing for you.