Skip to content
Open
31 changes: 31 additions & 0 deletions .changeset/stable-viewkeys-for-temp-ids.md
Original file line number Diff line number Diff line change
@@ -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
<li key={todoCollection.getViewKey(todo.id)}>
```

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
55 changes: 22 additions & 33 deletions docs/guides/mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | string, string>()

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
Expand All @@ -1162,25 +1149,24 @@ 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,
text: "New todo",
completed: false
})

// Use view key for rendering
// Use getViewKey() for stable rendering keys
const TodoList = () => {
const { data: todos } = useLiveQuery((q) =>
q.from({ todo: todoCollection })
Expand All @@ -1189,7 +1175,7 @@ const TodoList = () => {
return (
<ul>
{todos.map((todo) => (
<li key={getViewKey(todo.id)}> {/* Stable key */}
<li key={todoCollection.getViewKey(todo.id)}> {/* Stable key! */}
{todo.text}
</li>
))}
Expand All @@ -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
56 changes: 56 additions & 0 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
* <li key={collection.getViewKey(todo.id)}>
* {todo.text}
* </li>
* ))}
*/
public getViewKey(key: TKey): string {
const viewKey = this._state.viewKeyMap.get(key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a leaky abstraction. The caller (i.e. getViewKey) here should not be aware of the internal representation of the view keys. I'd prefer moving this getViewKey method to the CollectionStateManager and then here define a method that forwards the call:

public getViewKey(key: TKey): string {
  return this._state.getViewKey(key)
}

return viewKey ?? String(key)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keys are of type TKey = string | number. If we have no viewKey we turn the key into a string and use it as the viewKey. Is there any reason to have keys as string | number but viewKeys always as string? Would it make sense to also allow viewKeys to be of type string | number ?

}

/**
* 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also a leaky abstraction. Move this implementation to the CollectionStateManager and have this public method delegate the call to the state manager's method.

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
Expand Down
9 changes: 9 additions & 0 deletions packages/db/src/collection/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaky abstraction. If the data structure ever changes, this call needs to be changed too.

}

const mutation: PendingMutation<TOutput, `insert`> = {
mutationId: crypto.randomUUID(),
original: {},
Expand All @@ -198,6 +206,7 @@ export class CollectionMutationsManager<
createdAt: new Date(),
updatedAt: new Date(),
collection: this.collection,
viewKey,
}

mutations.push(mutation)
Expand Down
3 changes: 3 additions & 0 deletions packages/db/src/collection/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export class CollectionStateManager<
public optimisticUpserts = new Map<TKey, TOutput>()
public optimisticDeletes = new Set<TKey>()

// ViewKey mapping for stable rendering keys across ID transitions
public viewKeyMap = new Map<TKey, string>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to describe the type of the data structure in the field's name, i'd simplify the name to viewKeys.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs to be emptied in the cleanup at the bottom of this manger class.


// Cached size for performance
public size = 0

Expand Down
19 changes: 19 additions & 0 deletions packages/db/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface PendingMutation<
createdAt: Date
updatedAt: Date
collection: TCollection
/** Stable view key for rendering (survives ID transitions) */
viewKey?: string
}

/**
Expand Down Expand Up @@ -582,6 +584,23 @@ export interface BaseCollectionConfig<
*/
onDelete?: DeleteMutationFn<T, TKey, TUtils, TReturn>

/**
* 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
}

Expand Down
Loading