-
Notifications
You must be signed in to change notification settings - Fork 116
Add stable viewKey API to prevent UI re-renders during ID transitions #734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add stable viewKey API to prevent UI re-renders during ID transitions #734
Conversation
Created design proposal for stable viewKeys feature to prevent UI re-renders during temporary-to-real ID transitions. Includes: - DESIGN_ISSUE_19_STABLE_VIEWKEYS.md: Complete design proposal with API, implementation plan, usage examples, and alternatives considered - CODEBASE_EXPLORATION_ISSUE_19.md: Detailed analysis of current codebase architecture including storage, mutations, and transactions - KEY_FILES_REFERENCE.md: Quick reference for key files and structures - CODE_SNIPPETS_REFERENCE.md: Implementation examples from codebase Design proposes opt-in viewKey configuration with auto-generation and explicit linking API for mapping temporary IDs to real server IDs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…19) This adds built-in support for stable view keys that prevent UI re-renders when optimistic inserts transition from temporary IDs to real server-generated IDs. **Core Changes:** 1. **Storage**: Added `viewKeyMap: Map<TKey, string>` to CollectionStateManager to track stable view keys for items 2. **Configuration**: Added optional `viewKey` config to collections with two modes: - `generate`: Auto-generate viewKeys (e.g., `() => crypto.randomUUID()`) - `field`: Use existing field from item (e.g., `field: 'uuid'`) 3. **Type Definitions**: - Added `viewKey?: string` to PendingMutation interface - Added `viewKey?: string` to ChangeMessage interface - Added `viewKey` configuration to BaseCollectionConfig 4. **Mutation Handling**: Modified insert() to generate and store viewKeys based on configuration 5. **Public API**: Added two new collection methods: - `getViewKey(key)`: Returns stable viewKey for any key (temp or real) - `mapViewKey(tempKey, realKey)`: Links temp and real IDs to same viewKey 6. **Change Events**: Updated all change event emission to include viewKey in both optimistic and synced state changes **Usage Example:** ```typescript const todoCollection = createCollection({ getKey: (item) => item.id, viewKey: { generate: () => crypto.randomUUID() }, onInsert: async ({ transaction }) => { const tempId = transaction.mutations[0].modified.id const response = await api.create(...) // Link temp to real ID todoCollection.mapViewKey(tempId, response.id) await todoCollection.utils.refetch() }, }) // Use stable keys in React <li key={todoCollection.getViewKey(todo.id)}> ``` **Documentation**: Updated mutations.md to replace manual workaround with new built-in API, including usage examples and best practices. **Design Decisions:** - Opt-in via configuration (backward compatible) - Explicit linking via mapViewKey() for reliability - ViewKeys stored in collection metadata (not in items) - Mappings kept indefinitely (tiny memory footprint) Fixes #19 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
As suggested, removed the `field` option since users can already use
existing stable fields directly without this feature:
```tsx
// They can already do this:
<li key={todo.uuid}>
```
**Changes:**
1. **Simplified type**: `viewKey?: (item: T) => string` instead of object with generate/field options
2. **Cleaner config**: `viewKey: () => crypto.randomUUID()` instead of `viewKey: { generate: () => ... }`
3. **Removed field option**: No longer supports `field: 'uuid'` since it added no value
4. **Updated docs**: Removed alternative example using field option
The feature now focuses on its core value: **generating** stable keys
and **linking** temporary IDs to real IDs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- Removed design exploration documents (CODEBASE_EXPLORATION, DESIGN, etc.) - Added changeset describing the new feature - Added PR_DESCRIPTION.md with title and detailed body for the PR 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 518c983 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
PR has been created, no longer needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
More templates
@tanstack/angular-db
@tanstack/db
@tanstack/db-ivm
@tanstack/electric-db-collection
@tanstack/query-db-collection
@tanstack/react-db
@tanstack/rxdb-db-collection
@tanstack/solid-db
@tanstack/svelte-db
@tanstack/trailbase-db-collection
@tanstack/vue-db
commit: |
|
Size Change: +708 B (+0.84%) Total Size: 85 kB
ℹ️ View Unchanged
|
|
Size Change: 0 B Total Size: 2.89 kB ℹ️ View Unchanged
|
Added blank line before list for proper markdown formatting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
**DX Improvements:** 1. **Dev warning in mapViewKey**: Now warns when mapViewKey is called without an existing viewKey mapping, helping developers catch misconfiguration issues early. 2. **Clarify callback signature**: Updated docs and JSDoc to make it clear that the viewKey callback receives the item parameter, even if you ignore it with `_item`. Examples now show: - `viewKey: (_item) => crypto.randomUUID()` (ignore parameter) - `viewKey: (item) => ...` (use parameter if needed) These improve developer experience and reduce confusion around the API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Removed confusing _item convention from examples. In JavaScript/TypeScript, you can simply omit unused parameters, so: viewKey: () => crypto.randomUUID() is clearer and simpler than: viewKey: (_item) => crypto.randomUUID() The type signature still shows (item: T) => string for when you DO need the item, with a second example showing that usage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
ViewKeys are for rendering, not for event consumption. Consumers should call `collection.getViewKey(key)` directly when they need the stable key for rendering, rather than receiving it in change events. **What was removed:** - `viewKey?: string` field from ChangeMessage interface - All viewKey inclusions in event emissions throughout state.ts **What remains:** - `viewKey` in PendingMutation (useful mutation metadata) - `viewKeyMap` storage in CollectionStateManager - `getViewKey()` and `mapViewKey()` public API methods This simplifies the event system and keeps the concern of "stable rendering keys" separate from the concern of "data change notifications." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
Nit: i would rename |
kevin-dp
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall looks good to me. Left a few minor comments that i would like to see addressed.
| * ))} | ||
| */ | ||
| public getViewKey(key: TKey): string { | ||
| const viewKey = this._state.viewKeyMap.get(key) |
There was a problem hiding this comment.
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)
}| */ | ||
| public getViewKey(key: TKey): string { | ||
| const viewKey = this._state.viewKeyMap.get(key) | ||
| return viewKey ?? String(key) |
There was a problem hiding this comment.
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 ?
| * await collection.utils.refetch() | ||
| * } | ||
| */ | ||
| public mapViewKey(tempKey: TKey, realKey: TKey): void { |
There was a problem hiding this comment.
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.
| public optimisticDeletes = new Set<TKey>() | ||
|
|
||
| // ViewKey mapping for stable rendering keys across ID transitions | ||
| public viewKeyMap = new Map<TKey, string>() |
There was a problem hiding this comment.
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.
| if (this.config.viewKey) { | ||
| viewKey = this.config.viewKey(validatedData) | ||
| // Store viewKey mapping | ||
| this.state.viewKeyMap.set(key, viewKey) |
There was a problem hiding this comment.
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.
|
@KyleAMathews code all looks ok with @kevin-dp suggestions. I do however want a little time to mull over the api, I'm not sure if I'm 100% happy with it (I may turn out to be). Can we wait till tomorrow before merging so I have a little time to think about it. In brief, and absolutely not my final thoughts:
|
We should clean these up when the collection is GCed. But also this only runs when something is optimistically inserted — so it'll be very rare that people will insert thousands of rows client-side & be using this system. If they do happen to hit this edge case where there's a memory leak, we can politely ask them to invent their own system that they can constrain the memory 😆
Hmm perhaps? Though this is more a problem for the offline transactions package which does persist optimistic mutations. That's an edge case though that doesn't seem particularly urgent to solve. |
samwillis
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I'e slept on it and have a few more thoughts:
The presuppose of this is to remap server provided keys back to some sort of stable key that can persist through id transitions. This current design only works at the view layer, and is not providing a stable key through the live query layer - thats ok as the live query later has no concept of an update, only deletes and inserts, and so any change is always going to go through.
It's important to note that this only works on the source collection ids, not any composite ids that have been generated by the query layer when performing joins.
My main thought is that I don't seen the need to the viewKey config option where a user can provide a function to generate random stable ids. All that is needed is an api to map back to a previously client side generated stable id. I can think of two options here;
This is very similar to what you have now, but without the generating of random uuids or similar.
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(...)
// Update the internal map that maps from the server provided id, back to the temp id
todoCollection.mapStableKey(tempId, response.id)
await todoCollection.utils.refetch()
},
})
<li key={todoCollection.getStableKey(todo.id)}>
// getStableKey will return the old temp id if there is one, otherwise it returns the value passed to it.
Here we are just keeping a map of the new key back to the old key when the id is returned from the server. The user provided temp key is the "stable key" for the lifetime of the session, there is no need to generate "view keys" as uuids.
| public optimisticDeletes = new Set<TKey>() | ||
|
|
||
| // ViewKey mapping for stable rendering keys across ID transitions | ||
| public viewKeyMap = new Map<TKey, string>() |
There was a problem hiding this comment.
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.
Summary
Fixes #19
Adds built-in support for stable view keys that prevent UI flicker when optimistic inserts transition from temporary IDs to real server-generated IDs.
The Problem
When inserting items with temporary IDs (e.g., negative numbers) that are later replaced by real server IDs, React treats the key change as a component identity shift, causing:
Previously, developers had to manually maintain an external mapping from IDs to stable keys.
The Solution
Collections can now be configured with a
viewKeyfunction to automatically generate and track stable keys:API
New Configuration Option
viewKey?: (item: T) => string- Function to generate stable view keys for inserted itemsNew Collection Methods
getViewKey(key: TKey): string- Returns stable viewKey for any key (temporary or real). Falls back toString(key)if no viewKey is configured.mapViewKey(tempKey: TKey, realKey: TKey): void- Links temporary and real IDs to share the same stable viewKeyType Changes
viewKey?: stringtoPendingMutationinterfaceviewKey?: stringtoChangeMessageinterfaceImplementation Details
viewKeyMap: Map<TKey, string>toCollectionStateManagerto track stable keysinsert()if configuredmapViewKey()creates bidirectional mapping from both temp and real IDs to the same viewKeyBackward Compatibility
✅ Fully backward compatible - All changes are opt-in:
viewKeyconfig work exactly as beforegetViewKey()returnsString(key)when no viewKey is configuredDocumentation
Updated
/docs/guides/mutations.mdto replace the manual workaround with the new built-in API, including:Design Decisions
viewKey: () => uuid()instead of{ generate: () => uuid() }mapViewKey()call for reliability (vs auto-detection which would be fragile)Related