NSCollectionView Diffable Data Sources: Stable IDs and Snapshot Updates
Build predictable AppKit collection views with diffable snapshots, stable identifiers, reconfiguration, serialized updates, and safe selection restoration.
An NSCollectionView is a presentation of ordered model items, not the model itself. When an item is inserted or removed, its index path can change; code that treats the index path as permanent identity eventually edits or selects the wrong record. AppKit’s diffable data source lets an application describe a desired collection state as a snapshot of section and item identifiers, then apply that state to update the view. The framework computes the difference, while the application still owns identity, model storage, cell configuration, and update ordering.
This article covers the AppKit NSCollectionViewDiffableDataSource API and its Swift NSDiffableDataSourceSnapshot type. It is not a tutorial for UICollectionView on iOS, whose surrounding UI lifecycle is different. Check the SDK and deployment target for availability of the exact initializer and methods you use.
Identity is the central contract
Section and item identifiers must be unique within a snapshot and hashable. Use a stable identifier such as a database key, UUID, or immutable domain ID. A title, row number, mutable hash field, or object instance identity is usually a poor identifier: titles can collide or change, positions change after insertion, and mutating a hashed value while it is in a set-like structure violates the value’s identity contract.
The diffable data source asks its item provider to configure a view for an identifier at an index path. Keep a model store keyed by that identifier so the provider can resolve the current model. If an identifier is missing, do not silently configure an unrelated fallback item. Treat it as an invariant violation, log a sanitized diagnostic, and choose an explicit placeholder or safe failure behavior.
import AppKit
struct AssetID: Hashable {
let rawValue: UUID
}
struct Asset {
let id: AssetID
var name: String
var thumbnail: NSImage?
}
@MainActor
final class AssetGridController {
private var assetsByID: [AssetID: Asset] = [:]
private var dataSource: NSCollectionViewDiffableDataSource<Int, AssetID>!
func connect(_ collectionView: NSCollectionView) {
dataSource = NSCollectionViewDiffableDataSource<Int, AssetID>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, identifier in
guard let self, let asset = self.assetsByID[identifier] else {
return nil
}
let item = collectionView.makeItem(
withIdentifier: NSUserInterfaceItemIdentifier("AssetItem"),
for: indexPath
) as? AssetItem
item?.configure(with: asset)
return item
}
}
}
The item provider should be fast and side-effect-light. It can be called as the view requests visible cells, so do not perform synchronous network, disk, or expensive image decoding work there. Start asynchronous loading using the stable identifier, then update the relevant model and snapshot when a result arrives. If the same identifier was reused for a newer request, compare a generation token or content revision before applying the completion so stale results do not overwrite newer data.
Construct snapshots from a coherent state
A snapshot describes sections and item IDs in display order. Build it from one coherent model revision, not from several mutable collections that may change halfway through construction. Appending items to a section that does not exist, duplicate identifiers, or omitting a model record from the backing store create invalid or surprising states. Validate uniqueness at the boundary where data enters the view model.
@MainActor
func applyAssets(
_ assets: [Asset],
to dataSource: NSCollectionViewDiffableDataSource<Int, AssetID>,
store: inout [AssetID: Asset]
) {
let ids = assets.map(\.id)
precondition(Set(ids).count == ids.count, "Asset IDs must be unique")
store = Dictionary(uniqueKeysWithValues: assets.map { ($0.id, $0) })
var snapshot = NSDiffableDataSourceSnapshot<Int, AssetID>()
snapshot.appendSections([0])
snapshot.appendItems(ids, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: true)
}
This example replaces the store and view with one section. Production code with multiple sections should build section IDs and per-section item arrays from the same immutable view-state value. If an incoming refresh omits an item because it is temporarily unavailable, decide whether the item is truly deleted or should remain with a loading/error state. A network refresh should not accidentally erase locally edited state merely because a partial response arrived.
For incremental changes, retrieve the current snapshot and modify it, or construct a new complete snapshot from the canonical view state. Do not keep several independently mutable snapshots and apply them out of order. If updates can arrive faster than animations finish, coalesce them into the newest desired state or serialize them through one update coordinator. Each applied snapshot should correspond to a well-defined revision.
Reconfigure content versus changing membership
A snapshot diff updates membership and order, but an item can retain the same identifier while its displayed content changes. Mark changed identifiers for reconfiguration using the APIs available in the SDK, or reload the appropriate items when cell identity must be recreated. Reconfiguration is useful when layout identity is stable and only text, image, or status changed; reload is appropriate when the item needs a new view lifecycle. Ensure the item provider reads updated model state before applying the snapshot.
Do not generate a new UUID for every refresh just to force the view to redraw. That converts content edits into delete/insert pairs, disrupts animations, selection, keyboard focus, and accessibility continuity. The identifier answers “which logical item is this?”; the model version answers “which content revision is current?” Keep them separate.
The apply operation computes a difference when animations are enabled, which Apple documents as O(n) in the number of items in the snapshot. Large data sets should page or filter the data model, avoid rebuilding unnecessarily, and measure diff computation and cell configuration separately. Applying without animation can set the view to a new state without the diff-animation work, but it interrupts ongoing item animations and should be an intentional product choice rather than a blanket performance switch.
Serialize application and guard async results
Apple documents apply as safe to call from a background queue if the application does so consistently. The important property is consistency: do not apply some snapshots from the main queue and others from arbitrary background queues. For AppKit controllers, a main-actor update coordinator is often the simplest policy, especially because selection and view configuration are UI-owned. Build expensive immutable view state off the main actor, then deliver it to the chosen update executor.
When filtering or searching asynchronously, give each request a monotonically increasing generation. A late response from an earlier query must not replace a newer query’s snapshot. The completion closure for animated application is called from the main queue per Apple documentation; still keep completion work bounded and verify that the controller/document remains alive before changing UI state.
Never mutate assetsByID concurrently with a provider that reads it. In Swift, isolate the controller and store to the main actor or pass an immutable Sendable state snapshot across actor boundaries. If image decoding runs in the background, decode from immutable data, then publish the result on the model’s owning actor after checking the item ID and revision.
Preserve selection and interaction by identifier
Selection APIs often expose index paths, but application state should remember selected item identifiers. Before applying a snapshot, capture selected IDs from the current snapshot and current selection. After the update, restore only identifiers that still exist and are selectable. If removed items should clear selection, do that deliberately. Do not reuse old index paths after the diff; the same index path may now point to another item.
Context menus, drag-and-drop, keyboard actions, and accessibility actions should resolve the current identifier at invocation time, then mutate the model by ID. A drag may outlive the exact view instance it began with. Carry stable IDs in the drag payload and validate that the model item still exists when the drop is committed. If a cell is reused, cancel or rebind asynchronous work so an image for one item is not shown in another item’s reused view.
Supplementary and decoration views have their own configuration and layout lifecycles. A snapshot only captures section and item identity/order; it does not define layout metrics, supplementary content, or the full state of every reusable view. Test section insertions/removals with headers, empty sections, and custom layouts. If a layout depends on item counts, update it in coordination with snapshot application rather than assuming callbacks occur in a particular undocumented order.
Test invariants, not just animation appearance
Test initial load, insert, delete, move, content-only change, duplicate IDs, empty results, and rapid overlapping refreshes. Assert the final ordered identifiers from snapshot() and verify that each visible item resolves to the expected model. Include a selected item that moves, a selected item that disappears, and a delayed image response arriving after a newer model revision. UI animation screenshots can help detect layout regressions but cannot prove that the backing store and snapshot remain consistent.
For large collections, measure snapshot construction, diff application, layout, and cell work independently. Use realistic identifiers and item counts. A clean snapshot architecture has one canonical model revision, unique immutable identities, deterministic item configuration, serialized application, and explicitly defined selection behavior. Diffable data sources remove manual index bookkeeping; they do not remove the need for a coherent data model.
Related:
- NSDocument on macOS: Lifecycle, Autosave, and Version Recovery
- NSPasteboard on macOS: Typed Data, Lazy Providers, and File Promises
Sources: