All posts
Swift UIKit iOS UICollectionView

Diffable Data Sources: The Right Way to Drive Lists on iOS

Before Diffable Data Sources (iOS 13), keeping a collection view in sync with your model was a manual coordination problem. You called performBatchUpdates, carefully ordered inserts and deletes, and prayed you hadn’t miscounted — because a mismatch between your data source and the view would crash with the infamous NSInternalInconsistencyException. Every iOS developer has seen it.

Diffable Data Sources solve this by making the data source own the state. You describe what the UI should look like — a snapshot — and the framework computes and applies the diff automatically. No more manual index tracking. No more batch update arithmetic.

The Core Idea: Snapshots

A NSDiffableDataSourceSnapshot is a value type that describes the full state of your list: which sections exist, in what order, and which items are in each section.

var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.main])
snapshot.appendItems([itemA, itemB, itemC], toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)

Call apply again with a different snapshot and the framework diffs the two, then animates only the changed rows. You never touch insertRows, deleteRows, or reloadRows.

The type parameters — Section and Item — must both conform to Hashable. The diff algorithm uses hashValue to identify items across snapshots.


Setting Up: UICollectionView

The modern pattern combines UICollectionViewDiffableDataSource with UICollectionView.CellRegistration. Cell registration decouples cell configuration from the data source setup, and avoids the old dequeueReusableCell dance.

import UIKit

final class UserListViewController: UIViewController {

    // 1. Define your section and item types
    enum Section { case main }

    struct User: Hashable {
        let id: UUID
        let name: String
        let email: String
    }

    // 2. Declare the data source
    private var dataSource: UICollectionViewDiffableDataSource<Section, User>!
    private var collectionView: UICollectionView!

    override func viewDidLoad() {
        super.viewDidLoad()
        configureCollectionView()
        configureDataSource()
        applyInitialSnapshot()
    }

    private func configureCollectionView() {
        var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        config.trailingSwipeActionsConfigurationProvider = makeSwipeActions
        let layout = UICollectionViewCompositionalLayout.list(using: config)

        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(collectionView)
    }

    private func configureDataSource() {
        // 3. Register the cell
        let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, User> { cell, indexPath, user in
            var content = cell.defaultContentConfiguration()
            content.text = user.name
            content.secondaryText = user.email
            cell.contentConfiguration = content
        }

        // 4. Create the data source
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, user in
            collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: user)
        }
    }

    private func applyInitialSnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
        snapshot.appendSections([.main])
        snapshot.appendItems([
            User(id: UUID(), name: "Claudio Barbera", email: "claudio@example.com"),
            User(id: UUID(), name: "Anna Rossi", email: "anna@example.com"),
        ])
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

Updating the List

When new data arrives — from a network call, a search, a user action — build a new snapshot and apply it:

func update(with users: [User]) {
    var snapshot = NSDiffableDataSourceSnapshot<Section, User>()
    snapshot.appendSections([.main])
    snapshot.appendItems(users)
    dataSource.apply(snapshot, animatingDifferences: true)
}

The framework computes the diff between the current snapshot and this one. Items that moved animate into their new positions. Items that were removed fade out. Items that were added fade in. You wrote zero animation code.

Reconfiguring items without re-inserting

If an item’s identity hasn’t changed but its content has — say, a user updated their name — use reconfigureItems instead of deleting and re-inserting. This is faster and avoids losing selection state:

func refresh(user: User) {
    var snapshot = dataSource.snapshot()
    snapshot.reconfigureItems([user])
    dataSource.apply(snapshot, animatingDifferences: true)
}

reconfigureItems calls your cell registration closure again for that item, without triggering insert/delete animations.


Multiple Sections

Multiple sections are where diffable data sources really shine. Each section has its own identity, and items are scoped to their section.

enum Section: CaseIterable {
    case favorites
    case recent
    case all
}

func applySnapshot(favorites: [Contact], recent: [Contact], all: [Contact]) {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
    snapshot.appendSections(Section.allCases)
    snapshot.appendItems(favorites, toSection: .favorites)
    snapshot.appendItems(recent, toSection: .recent)
    snapshot.appendItems(all, toSection: .all)
    dataSource.apply(snapshot, animatingDifferences: true)
}

Sections can be added or removed between snapshots too — the framework animates the whole section in or out.


Section Snapshots (iOS 14+)

For expandable/collapsible sections — like a sidebar or an accordion list — use NSDiffableDataSourceSectionSnapshot. It supports parent-child relationships natively:

var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()

// Root items (visible when collapsed)
sectionSnapshot.append([.inbox, .drafts, .sent])

// Children of .inbox
sectionSnapshot.append([.promotions, .updates, .forums], to: .inbox)
sectionSnapshot.expand([.inbox])  // start expanded

dataSource.apply(sectionSnapshot, to: .mail, animatingDifferences: true)

Tapping a cell to expand/collapse is handled by the framework when using UICollectionLayoutListConfiguration with .sidebar or .sidebarPlain appearance. For custom layouts you manage it manually by updating the section snapshot on tap.


Reordering

Enabling drag-to-reorder requires implementing reorderingHandlers:

dataSource.reorderingHandlers.canReorderItem = { item in true }

dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
    guard let self else { return }
    // transaction.finalSnapshot reflects the new order
    var updated = transaction.finalSnapshot.itemIdentifiers
    self.viewModel.reorder(items: updated)
}

The framework handles the visual drag entirely. Your job is to persist the new order when didReorder fires.


Swipe Actions

With UICollectionLayoutListConfiguration:

private func makeSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    guard let user = dataSource.itemIdentifier(for: indexPath) else { return nil }

    let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in
        self?.delete(user: user)
        completion(true)
    }
    return UISwipeActionsConfiguration(actions: [delete])
}

private func delete(user: User) {
    var snapshot = dataSource.snapshot()
    snapshot.deleteItems([user])
    dataSource.apply(snapshot, animatingDifferences: true)
}

Notice the pattern: get the current snapshot, mutate it, reapply. This is the idiom you’ll use everywhere.


Hashable: The One Rule You Must Get Right

The diff algorithm depends entirely on Hashable. Two items with the same hash are considered the same item. If your model uses a mutable property in its hash, a content change makes the item look like a new item — causing an insert/delete instead of an in-place update.

The rule: base Hashable on identity only (usually an id), not content.

// ✅ Correct: identity-based hash
struct User: Hashable {
    let id: UUID
    var name: String
    var email: String

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: User, rhs: User) -> Bool {
        lhs.id == rhs.id
    }
}

// ❌ Wrong: default synthesis includes all properties
// Changing `name` makes the item look like a new identity to the diff
struct User: Hashable {
    let id: UUID
    var name: String  // included in default hash — wrong
}

When == returns true (same identity) but content differs, use reconfigureItems. When == returns false (different identity), the item is treated as removed+inserted.


UITableViewDiffableDataSource

The API is identical, with UITableViewDiffableDataSource instead:

dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { tableView, indexPath, item in
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = item.title
    return cell
}

Everything else — snapshots, apply, reconfigureItems, reordering — works the same way.


Search is the canonical use case that shows off diffable data sources best. Filter on every keystroke, no crash, no flicker:

final class SearchViewController: UIViewController, UISearchResultsUpdating {

    private var allContacts: [Contact] = []
    private var dataSource: UICollectionViewDiffableDataSource<Section, Contact>!

    func updateSearchResults(for searchController: UISearchController) {
        let query = searchController.searchBar.text ?? ""
        let filtered = query.isEmpty
            ? allContacts
            : allContacts.filter { $0.name.localizedCaseInsensitiveContains(query) }
        apply(filtered)
    }

    private func apply(_ contacts: [Contact]) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Contact>()
        snapshot.appendSections([.main])
        snapshot.appendItems(contacts)
        // animatingDifferences: false for search — too many changes, just reload
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

With the old data source, you’d need a filtered array, a full reloadData, and careful synchronization to avoid index mismatches during fast typing. Here it’s a single apply.


Comparison with the Old Approach

Old data sourceDiffable data source
UpdatesManual insertRows / deleteRowsapply(snapshot)
Crash riskHigh (index mismatch)None
AnimationManual performBatchUpdatesAutomatic
Hierarchy (expand/collapse)ManualSectionSnapshot built-in
ReorderingmoveRow(at:to:)reorderingHandlers
Thread safetyMain thread onlyCan apply from background

The last point is worth noting: apply can be called from a background queue (with animatingDifferences: false). The framework marshals the UI update to the main thread for you.


Diffable Data Sources are now the default way to drive lists in UIKit. If you’re starting a new screen with a UICollectionView or UITableView, there’s no good reason to reach for the old delegate-based data source. The API is cleaner, the crash surface is smaller, and the animation is automatic.