Diffable Data Sources in UIKit (UITableView + UICollectionView): Modern Swift 2026

Diffable Data Sources let you drive UITableView and UICollectionView from snapshots (a declarative “this is what the UI should show right now” state) instead of manually coordinating insertRows, deleteItems, performBatchUpdates, etc. Apple’s snapshot API requires unique, Hashable identifiers for sections and items. (Apple Developer)

Key idea: your UI is a projection of a snapshot. You build a snapshot from your current model state and apply it; the system computes the updates for you. (Apple Developer)


Table of contents


1. Core types and mental model

The main actors

The flow

  1. Pick identifier types (Hashable, unique). (Apple Developer)
  2. Create a diffable data source with a cell provider.
  3. Build a snapshot reflecting current model state.
  4. Apply snapshot to render UI updates.

2. Identifier design (most important “gotcha”)

Apple’s snapshot docs explicitly recommend Swift value types (struct/enum) for identifiers; if you use a Swift class it must be an NSObject subclass. (Apple Developer)

Rules of thumb

Good identifiers

Risky identifiers

Why? Because the itemIdentifier passed into your cell provider should be treated primarily as an identifier, and your “fresh data” should come from your own source-of-truth store (a common source of confusion when reloading). (Stack Overflow)


3. Modern “latest” apply syntax: async + @MainActor

In newer SDKs, apply is available as an async method and annotated with @MainActor. (Apple Developer)

// Modern (Swift Concurrency-friendly)
await dataSource.apply(snapshot, animatingDifferences: true)

There are also completion-handler variants in the API surface (useful for compatibility or when you need an explicit callback). (Apple Developer)

Important concurrency nuance (iOS 18+ era)

UIKit docs historically said you could call apply from a background queue if done consistently, but the API gained @MainActor annotations in the iOS 18 SDK and that created real-world Swift 6 friction; Jesse Squires documents the change and UIKit’s explanation. (Jesse Squires)

Practical guidance (2026):


4. UITableView examples

Note: Apple explicitly warns you shouldn’t swap out the table view’s data source after configuring a diffable data source; if you truly need a new data source, create a new table view and data source. (Apple Developer)

Shared model helpers used by table examples

import UIKit

enum Section: Hashable {
    case main
    case favorites
}

struct Contact: Identifiable, Hashable {
    let id: UUID
    var name: String
    var isFavorite: Bool

    // Stable identity: only ID participates in Hashable/Equatable.
    func hash(into hasher: inout Hasher) { hasher.combine(id) }
    static func == (lhs: Contact, rhs: Contact) -> Bool { lhs.id == rhs.id }
}

4.1 Minimal table view

This example uses:

@MainActor
final class ContactsTableViewController: UIViewController {

    private let tableView = UITableView(frame: .zero, style: .insetGrouped)

    // Keep a strong reference! tableView.dataSource is a weak reference in UIKit patterns.
    private var dataSource: UITableViewDiffableDataSource<Section, UUID>!

    // Source of truth store
    private var contactsByID: [UUID: Contact] = [:]
    private var orderedIDs: [UUID] = []

    private lazy var cellRegistration = UITableView.CellRegistration<UITableViewCell, Contact> { cell, _, contact in
        var content = cell.defaultContentConfiguration()
        content.text = contact.name
        content.secondaryText = contact.isFavorite ? "★ Favorite" : nil
        cell.contentConfiguration = content
        cell.accessoryType = .disclosureIndicator
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Contacts"

        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        configureDataSource()
        seedData()
        Task { await applySnapshot(animated: false) }
    }

    private func configureDataSource() {
        dataSource = UITableViewDiffableDataSource<Section, UUID>(tableView: tableView) { [weak self] tableView, indexPath, contactID in
            guard let self, let contact = self.contactsByID[contactID] else { return nil }
            // Note: cell registration item type can be the *model* (Contact),
            // even though diffable item identifier is UUID.
            return tableView.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: contact)
        }
    }

    private func seedData() {
        let contacts: [Contact] = [
            .init(id: UUID(), name: "Alicia", isFavorite: true),
            .init(id: UUID(), name: "Ben", isFavorite: false),
            .init(id: UUID(), name: "Chen", isFavorite: false),
        ]
        contactsByID = Dictionary(uniqueKeysWithValues: contacts.map { ($0.id, $0) })
        orderedIDs = contacts.map(\.id)
    }

    private func makeSnapshot() -> NSDiffableDataSourceSnapshot<Section, UUID> {
        var snapshot = NSDiffableDataSourceSnapshot<Section, UUID>()
        snapshot.appendSections([.main])
        snapshot.appendItems(orderedIDs, toSection: .main)
        return snapshot
    }

    private func applySnapshot(animated: Bool) async {
        let snapshot = makeSnapshot()
        await dataSource.apply(snapshot, animatingDifferences: animated)
    }
}

Why the cell registration uses Contact while diffable uses UUID: Apple notes the registration item type doesn’t have to match the diffable item identifier type. (Apple Developer)


4.2 Table with multiple sections + section headers

There are multiple approaches for headers:

Here’s the subclass approach:

final class HeaderedContactsDataSource: UITableViewDiffableDataSource<Section, UUID> {
    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        guard let sectionID = sectionIdentifier(for: section) else { return nil }
        switch sectionID {
        case .favorites: return "Favorites"
        case .main: return "All Contacts"
        }
    }
}

Then create it:

dataSource = HeaderedContactsDataSource(tableView: tableView) { [weak self] tableView, indexPath, id in
    guard let self, let contact = self.contactsByID[id] else { return nil }
    return tableView.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: contact)
}

Build a snapshot that splits items:

private func makeSnapshot() -> NSDiffableDataSourceSnapshot<Section, UUID> {
    var snapshot = NSDiffableDataSourceSnapshot<Section, UUID>()

    let favorites = orderedIDs.filter { contactsByID[$0]?.isFavorite == true }
    let others = orderedIDs.filter { contactsByID[$0]?.isFavorite != true }

    snapshot.appendSections([.favorites, .main])

    snapshot.appendItems(favorites, toSection: .favorites)
    snapshot.appendItems(others, toSection: .main)

    return snapshot
}

4.3 Swipe-to-delete + snapshot-driven deletes

For tables, it’s common to implement swipe actions in the view controller (delegate) and then update your model + apply a new snapshot.

extension ContactsTableViewController: UITableViewDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.delegate = self
    }

    func tableView(_ tableView: UITableView,
                   trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

        guard let id = dataSource.itemIdentifier(for: indexPath) else { return nil }

        let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
            guard let self else { return }
            self.contactsByID[id] = nil
            self.orderedIDs.removeAll { $0 == id }
            Task { await self.applySnapshot(animated: true) }
            done(true)
        }

        return UISwipeActionsConfiguration(actions: [delete])
    }
}

4.4 Updating a single row: reconfigureItems vs reloadItems

Apple’s guidance: if you want to update the contents of existing cells without replacing them, prefer reconfigureItems(_:) over reloadItems(_:) for performance, unless you truly need a full reload. (Apple Developer)

Example: toggle favorite flag for one contact.

@MainActor
func toggleFavorite(for id: UUID) async {
    guard var contact = contactsByID[id] else { return }
    contact.isFavorite.toggle()
    contactsByID[id] = contact

    var snapshot = dataSource.snapshot()
    snapshot.reconfigureItems([id]) // lightweight refresh of visible/prefetched cells
    await dataSource.apply(snapshot, animatingDifferences: true)
}

When to use reloadItems:

Also note a common confusion: reloading doesn’t mean the identifier changed; you still use your own backing store to provide updated content. (Stack Overflow)


4.5 Fast “reset” updates: applySnapshotUsingReloadData

applySnapshotUsingReloadData resets UI to match the snapshot without computing a diff and without animating changes. (Apple Developer)

This is useful when:

@MainActor
func replaceAll(with contacts: [Contact]) async {
    contactsByID = Dictionary(uniqueKeysWithValues: contacts.map { ($0.id, $0) })
    orderedIDs = contacts.map(\.id)

    let snapshot = makeSnapshot()
    await dataSource.applySnapshotUsingReloadData(snapshot)
}

A simple approach: keep a full list of IDs and a current filter, then build snapshot from the filtered IDs.

enum Filter {
    case all
    case favoritesOnly
    case query(String)
}

private var filter: Filter = .all

private func filteredIDs() -> [UUID] {
    switch filter {
    case .all:
        return orderedIDs
    case .favoritesOnly:
        return orderedIDs.filter { contactsByID[$0]?.isFavorite == true }
    case .query(let q):
        let lower = q.lowercased()
        return orderedIDs.filter { (contactsByID[$0]?.name.lowercased().contains(lower) ?? false) }
    }
}

private func makeSnapshot() -> NSDiffableDataSourceSnapshot<Section, UUID> {
    var snapshot = NSDiffableDataSourceSnapshot<Section, UUID>()
    snapshot.appendSections([.main])
    snapshot.appendItems(filteredIDs(), toSection: .main)
    return snapshot
}

Hook it up to a UISearchController and call Task { await applySnapshot(animated: true) } when the query changes.


5. UICollectionView examples

5.1 List-style collection view (modern replacement for many tables)

Using:

@MainActor
final class SettingsListViewController: UIViewController, UICollectionViewDelegate {

    enum Section: Hashable { case main }

    struct Setting: Hashable, Identifiable {
        let id: UUID
        var title: String
        var isOn: Bool
        func hash(into hasher: inout Hasher) { hasher.combine(id) }
        static func ==(l: Self, r: Self) -> Bool { l.id == r.id }
    }

    private var settingsByID: [UUID: Setting] = [:]
    private var orderedIDs: [UUID] = []

    private lazy var collectionView: UICollectionView = {
        var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        let layout = UICollectionViewCompositionalLayout.list(using: config)
        let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
        cv.delegate = self
        return cv
    }()

    private var dataSource: UICollectionViewDiffableDataSource<Section, UUID>!

    // IMPORTANT: create registrations outside the cell provider (Apple warns against doing so inside).
    // Doing it inside prevents reuse and can throw exceptions (iOS 15+).
    private lazy var cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Setting> { cell, _, setting in
        var content = UIListContentConfiguration.cell()
        content.text = setting.title
        cell.contentConfiguration = content

        // Use a checkmark accessory for demo
        cell.accessories = setting.isOn ? [.checkmark()] : []
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Settings"

        view.addSubview(collectionView)
        collectionView.frame = view.bounds
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        configureDataSource()
        seed()
        Task { await applySnapshot(animated: false) }
    }

    private func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource<Section, UUID>(collectionView: collectionView) { [weak self] collectionView, indexPath, id in
            guard let self, let setting = self.settingsByID[id] else { return nil }
            return collectionView.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: setting)
        }
    }

    private func seed() {
        let items: [Setting] = [
            .init(id: UUID(), title: "Wi‑Fi", isOn: true),
            .init(id: UUID(), title: "Bluetooth", isOn: false),
            .init(id: UUID(), title: "Airplane Mode", isOn: false),
        ]
        settingsByID = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
        orderedIDs = items.map(\.id)
    }

    private func makeSnapshot() -> NSDiffableDataSourceSnapshot<Section, UUID> {
        var snap = NSDiffableDataSourceSnapshot<Section, UUID>()
        snap.appendSections([.main])
        snap.appendItems(orderedIDs, toSection: .main)
        return snap
    }

    private func applySnapshot(animated: Bool) async {
        await dataSource.apply(makeSnapshot(), animatingDifferences: animated)
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let id = dataSource.itemIdentifier(for: indexPath),
              var setting = settingsByID[id] else { return }

        setting.isOn.toggle()
        settingsByID[id] = setting

        Task {
            var snap = dataSource.snapshot()
            snap.reconfigureItems([id])
            await dataSource.apply(snap, animatingDifferences: true)
        }
    }
}

5.2 Section headers with SupplementaryRegistration

UICollectionViewDiffableDataSource supports a supplementaryViewProvider closure for headers/footers. (Apple Developer) Modern pattern: UICollectionView.SupplementaryRegistration + dequeueConfiguredReusableSupplementary. (Apple Developer)

private lazy var headerRegistration =
    UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(
        elementKind: UICollectionView.elementKindSectionHeader
    ) { [weak self] header, _, indexPath in
        guard let self else { return }
        let sectionID = self.dataSource.sectionIdentifier(for: indexPath.section)

        var content = UIListContentConfiguration.groupedHeader()
        content.text = (sectionID == .main) ? "General" : "Other"
        header.contentConfiguration = content
    }

private func configureHeader() {
    dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
        guard let self, kind == UICollectionView.elementKindSectionHeader else { return nil }
        return collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath)
    }
}

5.3 Grid layout (compositional) + diffable

A classic “photo grid” example.

@MainActor
final class PhotoGridViewController: UIViewController {

    enum Section: Hashable { case main }

    struct Photo: Hashable, Identifiable {
        let id: UUID
        let title: String
        func hash(into hasher: inout Hasher) { hasher.combine(id) }
        static func ==(l: Self, r: Self) -> Bool { l.id == r.id }
    }

    private var photos: [Photo] = (0..<60).map {
        Photo(id: UUID(), title: "Photo \($0)")
    }

    private lazy var collectionView: UICollectionView = {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                              heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 4, leading: 4, bottom: 4, trailing: 4)

        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
                                               heightDimension: .fractionalWidth(1.0/3.0))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)

        let section = NSCollectionLayoutSection(group: group)
        let layout = UICollectionViewCompositionalLayout(section: section)

        return UICollectionView(frame: .zero, collectionViewLayout: layout)
    }()

    private var dataSource: UICollectionViewDiffableDataSource<Section, UUID>!

    private lazy var cellRegistration = UICollectionView.CellRegistration<UICollectionViewCell, Photo> { cell, _, photo in
        var content = UIListContentConfiguration.cell()
        content.text = photo.title
        cell.contentConfiguration = content

        var bg = UIBackgroundConfiguration.listPlainCell()
        bg.cornerRadius = 12
        cell.backgroundConfiguration = bg
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Grid"

        view.addSubview(collectionView)
        collectionView.frame = view.bounds
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        configureDataSource()
        Task { await applySnapshot(animated: false) }
    }

    private func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource<Section, UUID>(collectionView: collectionView) { [weak self] cv, indexPath, id in
            guard let self,
                  let photo = self.photos.first(where: { $0.id == id }) else { return nil }
            return cv.dequeueConfiguredReusableCell(using: self.cellRegistration, for: indexPath, item: photo)
        }
    }

    private func applySnapshot(animated: Bool) async {
        var snap = NSDiffableDataSourceSnapshot<Section, UUID>()
        snap.appendSections([.main])
        snap.appendItems(photos.map(\.id), toSection: .main)
        await dataSource.apply(snap, animatingDifferences: animated)
    }
}

5.4 Expandable outline / hierarchy with NSDiffableDataSourceSectionSnapshot

NSDiffableDataSourceSectionSnapshot is available for hierarchical structures (outline / expandable items). (Apple Developer)

Model:

struct Node: Hashable, Identifiable {
    let id: UUID
    let title: String
    var children: [Node] = []

    func hash(into hasher: inout Hasher) { hasher.combine(id) }
    static func ==(l: Self, r: Self) -> Bool { l.id == r.id }
}

Apply section snapshot to a section:

@MainActor
func applyOutline(nodes: [Node]) async {
    // Flatten to lookup store
    var store: [UUID: Node] = [:]
    func index(_ node: Node) {
        store[node.id] = node
        node.children.forEach(index)
    }
    nodes.forEach(index)
    self.nodesByID = store

    var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<UUID>()

    func append(_ node: Node, to parent: UUID?) {
        sectionSnapshot.append([node.id], to: parent)
        for child in node.children {
            append(child, to: node.id)
        }
    }

    // Add roots
    for root in nodes { append(root, to: nil) }

    await dataSource.apply(sectionSnapshot, to: .main, animatingDifferences: true)
}

You can also hook into sectionSnapshotHandlers for expand/collapse behaviors. (Apple Developer)


5.5 Reordering with reorderingHandlers + transactions

UICollectionViewDiffableDataSource supports reordering via reorderingHandlers. (Apple Developer) The system calls your handler after a reordering transaction so you can update your backing store. (Apple Developer)

@MainActor
func enableReordering() {
    dataSource.reorderingHandlers.canReorderItem = { _ in true }

    dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
        guard let self else { return }
        // transaction.finalSnapshot / transaction.difference can be used to update your store
        // Example strategy: rebuild orderedIDs from finalSnapshot.
        let final = transaction.finalSnapshot
        self.orderedIDs = final.itemIdentifiers
    }
}

6. Snapshot operations cookbook

A snapshot is just a value type representing the desired state. You add/delete/move sections and items, then apply.

Create sections + items

var snap = NSDiffableDataSourceSnapshot<Section, UUID>()
snap.appendSections([.main, .favorites])
snap.appendItems(mainIDs, toSection: .main)
snap.appendItems(favIDs, toSection: .favorites)
await dataSource.apply(snap, animatingDifferences: true)

Insert items at the top of a section

var snap = dataSource.snapshot()
snap.insertItems([newID], beforeItem: snap.itemIdentifiers(inSection: .main).first!)
await dataSource.apply(snap, animatingDifferences: true)

Delete items

var snap = dataSource.snapshot()
snap.deleteItems([idToDelete])
await dataSource.apply(snap, animatingDifferences: true)

Move an item

var snap = dataSource.snapshot()
snap.moveItem(id, afterItem: otherID)
await dataSource.apply(snap, animatingDifferences: true)

Reconfigure (lightweight refresh) vs reload

var snap = dataSource.snapshot()
snap.reconfigureItems([id]) // preferred when possible
await dataSource.apply(snap, animatingDifferences: true)

“No diff, no animation” reset

await dataSource.applySnapshotUsingReloadData(fullSnapshot) //

7. Concurrency + performance notes (Swift 6 era)

Apply is main-actor constrained

Modern SDK signatures show apply as @MainActor and async. (Apple Developer) If you were applying snapshots on background queues historically, expect Swift 6 strict concurrency to push you toward main-actor application. (Jesse Squires)

Diffing cost

Apple’s docs describe the diff as an O(n) operation where n is item count. (Apple Developer) If you have huge datasets, prefer:

UIKit team guidance (as reported) suggests diffing is usually not the bottleneck compared to cell creation/layout/measurement. (Jesse Squires)

iOS 15 behavior change: animatingDifferences: false no longer equals “reloadData”

Historically, animatingDifferences: false behaved like reloadData, but as of iOS 15 diffing always happens; to explicitly reload without diffing use applySnapshotUsingReloadData. (Jesse Squires)


8. Core Data integration pattern (FRC → diffable)

NSFetchedResultsController has a delegate method that can hand you a snapshot reference; SwiftLee shows a robust pattern for converting it to a typed snapshot, optionally reloading updated items, and applying it. (SwiftLee)

A simplified outline (collection view example):

@available(iOS 13.0, *)
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {

    guard let dataSource = collectionView.dataSource as? UICollectionViewDiffableDataSource<Int, NSManagedObjectID> else {
        return
    }

    var typed = snapshot as NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>

    // Optional: compute which IDs need reload (if object updated but ID unchanged)
    // typed.reloadItems(reloadIDs)

    let animate = collectionView.numberOfSections != 0
    dataSource.apply(typed, animatingDifferences: animate)
}

Key takeaways from the Core Data case:


9. Common pitfalls checklist

✅ Identifiers

✅ Data source lifecycle

✅ Cell registration

✅ Updating items

✅ Concurrency