NSTableView Diffable Data Sources: Row Identity, Reuse, and Serialized Updates
Use NSTableViewDiffableDataSource with stable row IDs, coherent model snapshots, safe view reuse, and measurable selection behavior under rapid updates.
An NSTableView is a presentation surface that asks a data source for rows and views. It does not own the application’s canonical records. A diffable data source lets the app describe section and item identity, then apply a snapshot of the desired state. Stable identity allows AppKit to compute insertions, removals, moves, and content refreshes without the application manually maintaining fragile row-number bookkeeping.
Diffable snapshots do not make an inconsistent model safe. Every snapshot must represent one coherent model revision, identifiers must be unique within the snapshot, and cell configuration must resolve each identifier to the matching current record. If a provider closure reads a mutable dictionary while another task is replacing its contents, the table can show a row from a different revision than the snapshot that requested it.
Choose durable row identifiers
Use a primary key that represents the logical row, such as a database ID or immutable domain identifier. Do not use the current row number or create a fresh UUID on every refresh. If the same logical record gets a different identifier after each query, the table sees delete/insert churn, which disrupts selection, animations, keyboard focus, and accessibility continuity.
Keep content revision separate from identity. A title change does not mean the row is a different entity. Build a new snapshot when ordering or membership changes, and reconfigure the stable item when only its visible content changes. If the product uses a composite row such as a group header plus records, use distinct identifier types or tagged values so a header ID cannot collide with a record ID.
import AppKit
struct RowModel {
let id: UUID
let title: String
}
func makeSnapshot(for rows: [RowModel]) -> NSDiffableDataSourceSnapshot<String, UUID> {
var snapshot = NSDiffableDataSourceSnapshot<String, UUID>()
snapshot.appendSections(["main"])
snapshot.appendItems(rows.map(\.id), toSection: "main")
return snapshot
}
The function builds an ordered snapshot but does not apply it. Before constructing it, validate that IDs are unique and that every ID resolves in the same immutable model snapshot used to configure cells. An assertion at the model boundary is cheaper to diagnose than a duplicate-identifier exception during an animated update.
Retain the data source and the model revision
Keep a strong property reference to the NSTableViewDiffableDataSource for as long as the table uses it. The table’s data source is not an ownership substitute for the controller. In a cell provider, read from a coherent record map captured or owned by the same update coordinator. Avoid looking records up by row index because a move can change that index between snapshot creation and cell configuration.
When data arrives asynchronously, tag it with a generation or query ID. Before applying, verify that it is still the newest result the UI wants to display. A slow search from an earlier filter must not replace a newer result set. Build the records map and snapshot together from an immutable value array, then swap or publish them as one UI-model revision.
apply(_:animatingDifferences:completion:) can animate changes. If snapshots arrive more quickly than animations complete, serialize or coalesce them. A practical policy is to keep the newest desired snapshot and apply it after the active update completes, rather than applying every intermediate state. The final table state should correspond to the latest model revision, not whichever network callback arrived last.
View reuse and deterministic configuration
An AppKit table can use cell-based or view-based presentation. In view-based tables, obtain reusable views using stable identifiers and configure every visible property each time a view is reused. Do not assume a reused text field, image view, accessibility label, tooltip, or progress indicator still has a default value. Clear state that is absent in the new record.
The cell provider should be fast and side-effect-light. It may be called while rows appear or update; do not perform synchronous disk or network work inside it. Start asynchronous loading using the row ID, update a cache or model when the result arrives, verify the row still has the same content generation, and then reconfigure that stable ID. If the table has scrolled and reused a view, the older result must not paint into the new row.
For custom row heights, compute layout from the current model and width. Window resizing can change wrapping and row height independently of model membership. Keep layout invalidation separate from snapshot identity so a resize does not turn every record into a delete/insert.
Selection, keyboard navigation, and edits
Selection is often stored as row indexes in table APIs, but product state should be stored by stable item ID. When snapshots reorder rows, derive the selected IDs from the current data source and restore selection only for IDs that still exist. Define what happens when a selected row disappears: clear selection, select a neighbor, or move focus to a container. Do not preserve an out-of-range row integer across a snapshot.
Editing introduces a commit boundary. When a cell editor changes a value, write the result to the canonical model, resolve validation and persistence, then apply a snapshot or reconfigure the item. Avoid having the table cell and backing model both act as independent authorities. A failed save should preserve the user’s edit or report the failure rather than silently displaying a snapshot that reverted it.
Support keyboard focus and accessibility across content updates. Stable row IDs help preserve logical selection, but they do not automatically guarantee the same first responder or accessible announcement after a large reload. Test VoiceOver, type selection, keyboard navigation, menu validation, and content changes while a row is selected.
Sections, empty results, and loading states
Section identifiers should also be stable and unique. Avoid changing a section ID merely because its title is localized; keep identity independent from display text. Use section header providers or table columns to present the current localized label.
Represent loading, no results, and error states explicitly. An empty snapshot can mean “no matching records,” while a separate placeholder view can mean “still loading.” Do not insert a fake domain row with an ID that can collide with real data. If the product uses a synthetic status row, use a distinct identifier namespace and exclude it from ordinary record actions.
Performance and update cadence
Measure model query, snapshot construction, diff application, row view creation, and drawing separately. Diffable data sources simplify bookkeeping, but a snapshot over tens of thousands of records can still consume time and memory. Paginate or filter the model before building the UI state when the product does not need every record at once.
Do not submit one snapshot for each low-level field change if a batch can express the same state. Coalesce rapid search or sort updates. Apply animations for meaningful user changes, and disable or reduce them for large refreshes when animation harms responsiveness. Preserve deterministic ordering when items compare equal; otherwise identical inputs can produce jittering moves.
Acceptance tests
Test initial load, empty data, insert, delete, move, title-only reconfiguration, duplicate IDs, overlapping query completions, selected item movement, selected item deletion, failed edits, row reuse with delayed images, resize, and VoiceOver. Assert that the snapshot’s ordered IDs equal the intended model order and that every displayed ID resolves to exactly one current model record.
For performance, measure snapshot build and apply time at realistic row counts and record the number of active row views while scrolling. Verify that a reusable row’s visual and accessibility state exactly matches its current identifier after a delayed asynchronous response. Capture an update generation in diagnostics so stale state can be reproduced.
The reliable table architecture has stable record IDs, a coherent immutable snapshot, serialized application, deterministic reusable-cell configuration, and selection stored as identity rather than position. Diffable data sources handle row movement; they do not remove the need for a correct model and lifecycle.
Related:
- NSCollectionView Diffable Data Sources: Stable IDs and Snapshot Updates
- AppKit Responder Chains: First Responder, Actions, and Keyboard Routing
Sources: