Skip to content

Commit f13c11e

Browse files
KyleAMathewsclaude
andauthored
feat: Add non-optimistic mutations support (#250)
Co-authored-by: Claude <[email protected]>
1 parent 1b48b49 commit f13c11e

File tree

7 files changed

+328
-3
lines changed

7 files changed

+328
-3
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Add non-optimistic mutations support
6+
7+
- Add `optimistic` option to insert, update, and delete operations
8+
- Default `optimistic: true` maintains backward compatibility
9+
- When `optimistic: false`, mutations only apply after server confirmation
10+
- Enables better control for server-validated operations and confirmation workflows

docs/overview.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,21 @@ insert([
575575
{ text: "Buy groceries", completed: false },
576576
{ text: "Walk dog", completed: false },
577577
])
578+
579+
// Insert with optimistic updates disabled
580+
myCollection.insert(
581+
{ text: "Server-validated item", completed: false },
582+
{ optimistic: false }
583+
)
584+
585+
// Insert with metadata and optimistic control
586+
myCollection.insert(
587+
{ text: "Custom item", completed: false },
588+
{
589+
metadata: { source: "import" },
590+
optimistic: true // default behavior
591+
}
592+
)
578593
```
579594

580595
##### `update`
@@ -598,6 +613,27 @@ update([todo1.id, todo2.id], (drafts) => {
598613
update(todo.id, { metadata: { reason: "user update" } }, (draft) => {
599614
draft.text = "Updated text"
600615
})
616+
617+
// Update without optimistic updates
618+
update(
619+
todo.id,
620+
{ optimistic: false },
621+
(draft) => {
622+
draft.status = "server-validated"
623+
}
624+
)
625+
626+
// Update with both metadata and optimistic control
627+
update(
628+
todo.id,
629+
{
630+
metadata: { reason: "admin update" },
631+
optimistic: false
632+
},
633+
(draft) => {
634+
draft.priority = "high"
635+
}
636+
)
601637
```
602638

603639
##### `delete`
@@ -611,6 +647,67 @@ delete([todo1.id, todo2.id])
611647
612648
// Delete with metadata
613649
delete(todo.id, { metadata: { reason: "completed" } })
650+
651+
// Delete without optimistic updates (waits for server confirmation)
652+
delete(todo.id, { optimistic: false })
653+
654+
// Delete with metadata and optimistic control
655+
delete(todo.id, {
656+
metadata: { reason: "admin deletion" },
657+
optimistic: false
658+
})
659+
```
660+
661+
#### Controlling optimistic behavior
662+
663+
By default, all mutations (`insert`, `update`, `delete`) apply optimistic updates immediately to provide instant feedback in your UI. However, there are cases where you may want to disable this behavior and wait for server confirmation before applying changes locally.
664+
665+
##### When to use `optimistic: false`
666+
667+
Consider disabling optimistic updates when:
668+
669+
- **Complex server-side processing**: Inserts that depend on server-side generation (e.g., cascading foreign keys, computed fields)
670+
- **Validation requirements**: Operations where backend validation might reject the change
671+
- **Confirmation workflows**: Deletes where UX should wait for confirmation before removing data
672+
- **Batch operations**: Large operations where optimistic rollback would be disruptive
673+
674+
##### Behavior differences
675+
676+
**`optimistic: true` (default)**:
677+
- Immediately applies mutation to the local store
678+
- Provides instant UI feedback
679+
- Requires rollback if server rejects the mutation
680+
- Best for simple, predictable operations
681+
682+
**`optimistic: false`**:
683+
- Does not modify local store until server confirms
684+
- No immediate UI feedback, but no rollback needed
685+
- UI updates only after successful server response
686+
- Best for complex or validation-heavy operations
687+
688+
```typescript
689+
// Example: Critical deletion that needs confirmation
690+
const handleDeleteAccount = () => {
691+
// Don't remove from UI until server confirms
692+
userCollection.delete(userId, { optimistic: false })
693+
}
694+
695+
// Example: Server-generated data
696+
const handleCreateInvoice = () => {
697+
// Server generates invoice number, tax calculations, etc.
698+
invoiceCollection.insert(invoiceData, { optimistic: false })
699+
}
700+
701+
// Example: Mixed approach in same transaction
702+
tx.mutate(() => {
703+
// Instant UI feedback for simple change
704+
todoCollection.update(todoId, (draft) => {
705+
draft.completed = true
706+
})
707+
708+
// Wait for server confirmation for complex change
709+
auditCollection.insert(auditRecord, { optimistic: false })
710+
})
614711
```
615712

616713
## Usage examples

packages/db/src/collection.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ export class CollectionImpl<
634634
// Apply active transactions only (completed transactions are handled by sync operations)
635635
for (const transaction of activeTransactions) {
636636
for (const mutation of transaction.mutations) {
637-
if (mutation.collection === this) {
637+
if (mutation.collection === this && mutation.optimistic) {
638638
switch (mutation.type) {
639639
case `insert`:
640640
case `update`:
@@ -1064,7 +1064,7 @@ export class CollectionImpl<
10641064
for (const transaction of this.transactions.values()) {
10651065
if (![`completed`, `failed`].includes(transaction.state)) {
10661066
for (const mutation of transaction.mutations) {
1067-
if (mutation.collection === this) {
1067+
if (mutation.collection === this && mutation.optimistic) {
10681068
switch (mutation.type) {
10691069
case `insert`:
10701070
case `update`:
@@ -1358,6 +1358,7 @@ export class CollectionImpl<
13581358
key,
13591359
metadata: config?.metadata as unknown,
13601360
syncMetadata: this.config.sync.getSyncMetadata?.() || {},
1361+
optimistic: config?.optimistic ?? true,
13611362
type: `insert`,
13621363
createdAt: new Date(),
13631364
updatedAt: new Date(),
@@ -1573,6 +1574,7 @@ export class CollectionImpl<
15731574
string,
15741575
unknown
15751576
>,
1577+
optimistic: config.optimistic ?? true,
15761578
type: `update`,
15771579
createdAt: new Date(),
15781580
updatedAt: new Date(),
@@ -1696,6 +1698,7 @@ export class CollectionImpl<
16961698
string,
16971699
unknown
16981700
>,
1701+
optimistic: config?.optimistic ?? true,
16991702
type: `delete`,
17001703
createdAt: new Date(),
17011704
updatedAt: new Date(),

packages/db/src/proxy.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,6 @@ export function createChangeProxy<
505505
if (typeof callback === `function`) {
506506
// Replace the original callback with our wrapped version
507507
const wrappedCallback = function (
508-
// eslint-disable-next-line
509508
this: unknown,
510509
// eslint-disable-next-line
511510
value: unknown,

packages/db/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export interface PendingMutation<
6969
type: OperationType
7070
metadata: unknown
7171
syncMetadata: Record<string, unknown>
72+
/** Whether this mutation should be applied optimistically (defaults to true) */
73+
optimistic: boolean
7274
createdAt: Date
7375
updatedAt: Date
7476
collection: Collection<T, any, any>
@@ -203,10 +205,14 @@ export type StandardSchemaAlias<T = unknown> = StandardSchema<T>
203205

204206
export interface OperationConfig {
205207
metadata?: Record<string, unknown>
208+
/** Whether to apply optimistic updates immediately. Defaults to true. */
209+
optimistic?: boolean
206210
}
207211

208212
export interface InsertConfig {
209213
metadata?: Record<string, unknown>
214+
/** Whether to apply optimistic updates immediately. Defaults to true. */
215+
optimistic?: boolean
210216
}
211217

212218
export type UpdateMutationFnParams<

0 commit comments

Comments
 (0)