diff --git a/.changeset/stable-viewkeys-for-temp-ids.md b/.changeset/stable-viewkeys-for-temp-ids.md
new file mode 100644
index 000000000..f6e7bd410
--- /dev/null
+++ b/.changeset/stable-viewkeys-for-temp-ids.md
@@ -0,0 +1,31 @@
+---
+"@tanstack/db": patch
+---
+
+Add stable `viewKey` support to prevent UI re-renders during temporary-to-real ID transitions. When inserting items with temporary IDs that are later replaced by server-generated IDs, React components would previously unmount and remount, causing loss of focus and visual flicker.
+
+Collections can now be configured with a `viewKey` function to generate stable keys:
+
+```typescript
+const todoCollection = createCollection({
+ getKey: (item) => item.id,
+ viewKey: () => crypto.randomUUID(),
+ onInsert: async ({ transaction }) => {
+ const tempId = transaction.mutations[0].modified.id
+ const response = await api.create(...)
+
+ // Link temporary and real IDs to same viewKey
+ todoCollection.mapViewKey(tempId, response.id)
+ await todoCollection.utils.refetch()
+ },
+})
+
+// Use stable keys in React
+
+```
+
+New APIs:
+
+- `collection.getViewKey(key)` - Returns stable viewKey for any key (temporary or real)
+- `collection.mapViewKey(tempKey, realKey)` - Links temporary and real IDs to share the same viewKey
+- `viewKey` configuration option - Function to generate stable view keys for inserted items
diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md
index 24e8aba92..3414695bb 100644
--- a/docs/guides/mutations.md
+++ b/docs/guides/mutations.md
@@ -1127,30 +1127,17 @@ const TodoItem = ({ todo, isPersisted }: { todo: Todo, isPersisted: boolean }) =
}
```
-### Solution 3: Maintain a View Key Mapping
+### Solution 3: Use Built-in Stable View Keys
-To avoid UI flicker while keeping optimistic updates, maintain a separate mapping from IDs (both temporary and real) to stable view keys:
+TanStack DB provides built-in support for stable view keys that prevent UI flicker during ID transitions:
```tsx
-// Create a mapping API
-const idToViewKey = new Map()
-
-function getViewKey(id: number | string): string {
- if (!idToViewKey.has(id)) {
- idToViewKey.set(id, crypto.randomUUID())
- }
- return idToViewKey.get(id)!
-}
-
-function linkIds(tempId: number, realId: number) {
- const viewKey = getViewKey(tempId)
- idToViewKey.set(realId, viewKey)
-}
-
-// Configure collection to link IDs when real ID comes back
+// Configure collection with automatic view key generation
const todoCollection = createCollection({
id: "todos",
- // ... other options
+ getKey: (item) => item.id,
+ // Enable automatic view key generation
+ viewKey: () => crypto.randomUUID(),
onInsert: async ({ transaction }) => {
const mutation = transaction.mutations[0]
const tempId = mutation.modified.id
@@ -1162,17 +1149,16 @@ const todoCollection = createCollection({
})
const realId = response.id
- // Link temp ID to same view key as real ID
- linkIds(tempId, realId)
+ // Link temp ID to real ID (they share the same viewKey)
+ todoCollection.mapViewKey(tempId, realId)
// Wait for sync back
await todoCollection.utils.refetch()
},
})
-// When inserting with temp ID
+// Insert with temp ID - viewKey is automatically generated
const tempId = -Math.floor(Math.random() * 1000000) + 1
-const viewKey = getViewKey(tempId) // Creates and stores mapping
todoCollection.insert({
id: tempId,
@@ -1180,7 +1166,7 @@ todoCollection.insert({
completed: false
})
-// Use view key for rendering
+// Use getViewKey() for stable rendering keys
const TodoList = () => {
const { data: todos } = useLiveQuery((q) =>
q.from({ todo: todoCollection })
@@ -1189,7 +1175,7 @@ const TodoList = () => {
return (
{todos.map((todo) => (
- - {/* Stable key */}
+
- {/* Stable key! */}
{todo.text}
))}
@@ -1198,14 +1184,17 @@ const TodoList = () => {
}
```
-This pattern maintains a stable key throughout the temporary → real ID transition, preventing your UI framework from unmounting and remounting the component. The view key is stored outside the collection items, so you don't need to add extra fields to your data model.
+**How it works:**
-### Best Practices
+1. The `viewKey` function creates a stable UUID when items are inserted
+2. `mapViewKey(tempId, realId)` links the temporary and real IDs to share the same viewKey
+3. `getViewKey(id)` returns the stable viewKey for any ID (temp or real)
+4. React uses the stable viewKey, preventing unmount/remount during ID transitions
-1. **Use UUIDs when possible**: Client-generated UUIDs eliminate the temporary ID problem
-2. **Generate temporary IDs deterministically**: Use negative numbers or a specific pattern to distinguish temporary IDs from real ones
-3. **Disable operations on temporary items**: Disable delete/update buttons until persistence completes
-4. **Maintain view key mappings**: Create a mapping between IDs and stable view keys for rendering
+### Best Practices
-> [!NOTE]
-> There's an [open issue](https://github.com/TanStack/db/issues/19) to add better built-in support for temporary ID handling in TanStack DB. This would automate the view key pattern and make it easier to work with server-generated IDs.
+1. **Use UUIDs when possible**: Client-generated UUIDs as IDs eliminate the temporary ID problem entirely
+2. **Enable viewKeys for server-generated IDs**: Use the `viewKey` config option to enable automatic stable keys
+3. **Link IDs in insert handlers**: Always call `mapViewKey(tempId, realId)` after getting the real ID from the server
+4. **Use getViewKey() in renders**: Call `collection.getViewKey(item.id)` for React keys instead of using the ID directly
+5. **Generate temporary IDs deterministically**: Use negative numbers or a specific pattern to distinguish temporary IDs from real ones
diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts
index 5a4ab3c07..256c02637 100644
--- a/packages/db/src/collection/index.ts
+++ b/packages/db/src/collection/index.ts
@@ -450,6 +450,62 @@ export class CollectionImpl<
return this.config.getKey(item)
}
+ /**
+ * Get a stable view key for a given item key.
+ * If viewKey configuration is enabled, returns the stable viewKey.
+ * Otherwise, returns the key as a string (backward compatible behavior).
+ *
+ * @param key - The item key to get the view key for
+ * @returns The stable view key as a string
+ *
+ * @example
+ * // Use in React components for stable keys during ID transitions
+ * {todos.map((todo) => (
+ * -
+ * {todo.text}
+ *
+ * ))}
+ */
+ public getViewKey(key: TKey): string {
+ const viewKey = this._state.viewKeyMap.get(key)
+ return viewKey ?? String(key)
+ }
+
+ /**
+ * Link a temporary key to a real key, maintaining the same stable viewKey.
+ * This is used when server-generated IDs replace temporary client IDs.
+ *
+ * @param tempKey - The temporary key used during optimistic insert
+ * @param realKey - The real key assigned by the server
+ *
+ * @example
+ * // In your insert handler, link temp ID to real ID
+ * onInsert: async ({ transaction }) => {
+ * const mutation = transaction.mutations[0]
+ * const tempId = mutation.modified.id
+ *
+ * const response = await api.todos.create(mutation.modified)
+ * const realId = response.id
+ *
+ * // Link the IDs so they share the same viewKey
+ * collection.mapViewKey(tempId, realId)
+ *
+ * await collection.utils.refetch()
+ * }
+ */
+ public mapViewKey(tempKey: TKey, realKey: TKey): void {
+ const viewKey = this._state.viewKeyMap.get(tempKey)
+ if (viewKey) {
+ // Link real key to the same viewKey
+ this._state.viewKeyMap.set(realKey, viewKey)
+ } else if (process.env.NODE_ENV !== `production`) {
+ console.warn(
+ `[TanStack DB] mapViewKey called for tempKey "${String(tempKey)}" but no viewKey was found. ` +
+ `Make sure you've configured the collection with a viewKey function.`
+ )
+ }
+ }
+
/**
* Creates an index on a collection for faster queries.
* Indexes significantly improve query performance by allowing constant time lookups
diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts
index 278234f47..188c82f4d 100644
--- a/packages/db/src/collection/mutations.ts
+++ b/packages/db/src/collection/mutations.ts
@@ -176,6 +176,14 @@ export class CollectionMutationsManager<
}
const globalKey = this.generateGlobalKey(key, item)
+ // Generate viewKey if configured
+ let viewKey: string | undefined
+ if (this.config.viewKey) {
+ viewKey = this.config.viewKey(validatedData)
+ // Store viewKey mapping
+ this.state.viewKeyMap.set(key, viewKey)
+ }
+
const mutation: PendingMutation = {
mutationId: crypto.randomUUID(),
original: {},
@@ -198,6 +206,7 @@ export class CollectionMutationsManager<
createdAt: new Date(),
updatedAt: new Date(),
collection: this.collection,
+ viewKey,
}
mutations.push(mutation)
diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts
index f7b03da33..155951878 100644
--- a/packages/db/src/collection/state.ts
+++ b/packages/db/src/collection/state.ts
@@ -50,6 +50,9 @@ export class CollectionStateManager<
public optimisticUpserts = new Map()
public optimisticDeletes = new Set()
+ // ViewKey mapping for stable rendering keys across ID transitions
+ public viewKeyMap = new Map()
+
// Cached size for performance
public size = 0
diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts
index 73c1fc4fe..bd3058e1c 100644
--- a/packages/db/src/types.ts
+++ b/packages/db/src/types.ts
@@ -83,6 +83,8 @@ export interface PendingMutation<
createdAt: Date
updatedAt: Date
collection: TCollection
+ /** Stable view key for rendering (survives ID transitions) */
+ viewKey?: string
}
/**
@@ -582,6 +584,23 @@ export interface BaseCollectionConfig<
*/
onDelete?: DeleteMutationFn
+ /**
+ * Optional function to generate stable view keys for items.
+ * This prevents UI re-renders during temporary-to-real ID transitions.
+ *
+ * When enabled, call `collection.mapViewKey(tempId, realId)` in your
+ * insert handler to link the temporary and real IDs to the same viewKey.
+ *
+ * @example
+ * // Auto-generate view keys with UUIDs
+ * viewKey: () => crypto.randomUUID()
+ *
+ * @example
+ * // Or derive from item property if needed
+ * viewKey: (item) => `view-${item.userId}-${crypto.randomUUID()}`
+ */
+ viewKey?: (item: T) => string
+
utils?: TUtils
}