Skip to content

Conversation

@KyleAMathews
Copy link
Collaborator

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:

  1. UI Flicker - Components unmount and remount, resetting state
  2. Lost Focus - Input fields lose focus during ID transition
  3. Visual Jank - Animations restart, scroll position resets

Previously, developers had to manually maintain an external mapping from IDs to stable keys.

The Solution

Collections can now be configured with a viewKey function to automatically generate and track stable keys:

const todoCollection = createCollection({
  getKey: (item) => item.id,
  viewKey: () => crypto.randomUUID(), // ← Auto-generate stable keys
  onInsert: async ({ transaction }) => {
    const tempId = transaction.mutations[0].modified.id
    const response = await api.create(...)

    // Link temp ID to real ID - they share the same viewKey
    todoCollection.mapViewKey(tempId, response.id)
    await todoCollection.utils.refetch()
  },
})

// Use stable keys in React - no more flicker!
{todos.map((todo) => (
  <li key={todoCollection.getViewKey(todo.id)}>
    {todo.text}
  </li>
))}

API

New Configuration Option

  • viewKey?: (item: T) => string - Function to generate stable view keys for inserted items

New Collection Methods

  • getViewKey(key: TKey): string - Returns stable viewKey for any key (temporary or real). Falls back to String(key) if no viewKey is configured.

  • mapViewKey(tempKey: TKey, realKey: TKey): void - Links temporary and real IDs to share the same stable viewKey

Type Changes

  • Added viewKey?: string to PendingMutation interface
  • Added viewKey?: string to ChangeMessage interface

Implementation Details

  1. Storage: Added viewKeyMap: Map<TKey, string> to CollectionStateManager to track stable keys
  2. Generation: ViewKeys are automatically generated during insert() if configured
  3. Linking: mapViewKey() creates bidirectional mapping from both temp and real IDs to the same viewKey
  4. Events: All change events (insert/update/delete) now include viewKey for subscribers
  5. Persistence: ViewKeys kept indefinitely (tiny memory overhead ~50 bytes per item)

Backward Compatibility

Fully backward compatible - All changes are opt-in:

  • Collections without viewKey config work exactly as before
  • getViewKey() returns String(key) when no viewKey is configured
  • No breaking changes to existing APIs

Documentation

Updated /docs/guides/mutations.md to replace the manual workaround with the new built-in API, including:

  • Complete usage example
  • How it works explanation
  • Best practices

Design Decisions

  1. Opt-in via configuration - Only active when explicitly enabled
  2. Function instead of object - Simple viewKey: () => uuid() instead of { generate: () => uuid() }
  3. Explicit linking - Manual mapViewKey() call for reliability (vs auto-detection which would be fragile)
  4. Collection-level storage - ViewKeys stored in collection metadata, not polluting item data
  5. Indefinite retention - Mappings kept forever for consistency (negligible memory impact)

Related

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-bot
Copy link

changeset-bot bot commented Oct 30, 2025

🦋 Changeset detected

Latest commit: 518c983

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch
todos Patch
@tanstack/db-example-react-todo Patch

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>
@pkg-pr-new
Copy link

pkg-pr-new bot commented Oct 30, 2025

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@734

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@734

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@734

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@734

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@734

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@734

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@734

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@734

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@734

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@734

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@734

commit: 518c983

@github-actions
Copy link
Contributor

github-actions bot commented Oct 30, 2025

Size Change: +708 B (+0.84%)

Total Size: 85 kB

Filename Size Change
./packages/db/dist/esm/collection/index.js 3.87 kB +642 B (+19.87%) 🚨
./packages/db/dist/esm/collection/mutations.js 2.57 kB +55 B (+2.18%)
./packages/db/dist/esm/collection/state.js 3.81 kB +11 B (+0.29%)
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.63 kB
./packages/db/dist/esm/collection/changes.js 1.01 kB
./packages/db/dist/esm/collection/events.js 413 B
./packages/db/dist/esm/collection/indexes.js 1.16 kB
./packages/db/dist/esm/collection/lifecycle.js 1.8 kB
./packages/db/dist/esm/collection/subscription.js 2.2 kB
./packages/db/dist/esm/collection/sync.js 2.2 kB
./packages/db/dist/esm/deferred.js 230 B
./packages/db/dist/esm/errors.js 3.48 kB
./packages/db/dist/esm/event-emitter.js 798 B
./packages/db/dist/esm/index.js 1.62 kB
./packages/db/dist/esm/indexes/auto-index.js 794 B
./packages/db/dist/esm/indexes/base-index.js 835 B
./packages/db/dist/esm/indexes/btree-index.js 2 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.21 kB
./packages/db/dist/esm/indexes/reverse-index.js 577 B
./packages/db/dist/esm/local-only.js 967 B
./packages/db/dist/esm/local-storage.js 2.42 kB
./packages/db/dist/esm/optimistic-action.js 294 B
./packages/db/dist/esm/proxy.js 3.86 kB
./packages/db/dist/esm/query/builder/functions.js 615 B
./packages/db/dist/esm/query/builder/index.js 4.04 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 938 B
./packages/db/dist/esm/query/compiler/evaluators.js 1.55 kB
./packages/db/dist/esm/query/compiler/expressions.js 760 B
./packages/db/dist/esm/query/compiler/group-by.js 2.04 kB
./packages/db/dist/esm/query/compiler/index.js 2.21 kB
./packages/db/dist/esm/query/compiler/joins.js 2.65 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.43 kB
./packages/db/dist/esm/query/compiler/select.js 1.28 kB
./packages/db/dist/esm/query/ir.js 785 B
./packages/db/dist/esm/query/live-query-collection.js 404 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.54 kB
./packages/db/dist/esm/query/live/collection-registry.js 233 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.11 kB
./packages/db/dist/esm/query/optimizer.js 3.26 kB
./packages/db/dist/esm/scheduler.js 1.29 kB
./packages/db/dist/esm/SortedMap.js 1.24 kB
./packages/db/dist/esm/transactions.js 3.05 kB
./packages/db/dist/esm/utils.js 1.01 kB
./packages/db/dist/esm/utils/browser-polyfills.js 365 B
./packages/db/dist/esm/utils/btree.js 6.01 kB
./packages/db/dist/esm/utils/comparison.js 754 B
./packages/db/dist/esm/utils/index-optimization.js 1.73 kB

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Oct 30, 2025

Size Change: 0 B

Total Size: 2.89 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 168 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.41 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.31 kB

compressed-size-action::react-db-package-size

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>
@kevin-dp
Copy link
Contributor

kevin-dp commented Nov 3, 2025

Nit: i would rename mapViewKey to setViewKey or storeViewKey.

Copy link
Contributor

@kevin-dp kevin-dp left a 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)
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)
}

*/
public getViewKey(key: TKey): string {
const viewKey = this._state.viewKeyMap.get(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 ?

* 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.

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.

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.

@samwillis
Copy link
Collaborator

samwillis commented Nov 3, 2025

@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:

  1. it would be nice to use the actual PK when there is one.
  2. I think the view keys will grow un-bound even when using a collection that GC's rows.
  3. does this have any impact on persistence when that comes, do we need to persist the view keys (writing that out, I don't think we do...).

@KyleAMathews
Copy link
Collaborator Author

I think the view keys will grow un-bound even when using a collection that GC's rows.

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 😆

does this have any impact on persistence when that comes, do we need to persist the view keys (writing that out, I don't think we do...).

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.

Copy link
Collaborator

@samwillis samwillis left a 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>()
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.

@KyleAMathews KyleAMathews moved this to In Progress in 1.0.0 release Nov 4, 2025
@KyleAMathews KyleAMathews moved this from In Progress to Todo in 1.0.0 release Nov 4, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

Support for Stable ViewKeys to Prevent UI Re-renders on ID Mapping

5 participants