Skip to content

Commit 4e053ab

Browse files
authored
Add createOptimisticAction helper that replaces useOptimisticMutation (#210)
1 parent 845348c commit 4e053ab

File tree

11 files changed

+421
-115
lines changed

11 files changed

+421
-115
lines changed

.changeset/solid-pandas-draw.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@tanstack/react-db": patch
3+
"@tanstack/vue-db": patch
4+
"@tanstack/db": patch
5+
---
6+
7+
Add createOptimisticAction helper that replaces useOptimisticMutation
8+
9+
An example of converting a `useOptimisticMutation` hook to `createOptimisticAction`. Now all optimistic & server mutation logic are consolidated.
10+
11+
```diff
12+
-import { useOptimisticMutation } from '@tanstack/react-db'
13+
+import { createOptimisticAction } from '@tanstack/react-db'
14+
+
15+
+// Create the `addTodo` action, passing in your `mutationFn` and `onMutate`.
16+
+const addTodo = createOptimisticAction<string>({
17+
+ onMutate: (text) => {
18+
+ // Instantly applies the local optimistic state.
19+
+ todoCollection.insert({
20+
+ id: uuid(),
21+
+ text,
22+
+ completed: false
23+
+ })
24+
+ },
25+
+ mutationFn: async (text) => {
26+
+ // Persist the todo to your backend
27+
+ const response = await fetch('/api/todos', {
28+
+ method: 'POST',
29+
+ body: JSON.stringify({ text, completed: false }),
30+
+ })
31+
+ return response.json()
32+
+ }
33+
+})
34+
35+
const Todo = () => {
36+
- // Create the `addTodo` mutator, passing in your `mutationFn`.
37+
- const addTodo = useOptimisticMutation({ mutationFn })
38+
-
39+
const handleClick = () => {
40+
- // Triggers the mutationFn
41+
- addTodo.mutate(() =>
42+
- // Instantly applies the local optimistic state.
43+
- todoCollection.insert({
44+
- id: uuid(),
45+
- text: '🔥 Make app faster',
46+
- completed: false
47+
- })
48+
- )
49+
+ // Triggers the onMutate and then the mutationFn
50+
+ addTodo('🔥 Make app faster')
51+
}
52+
53+
return <Button onClick={ handleClick } />
54+
}
55+
```

docs/overview.md

Lines changed: 62 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,13 @@ Mutations are based on a `Transaction` primitive.
117117

118118
For simple state changes, directly mutating the collection and persisting with the operator handlers is enough.
119119

120-
But for more complex use cases, you can directly create custom mutators with `useOptimisticMutation`. This lets you do things such as do transactions with multiple mutations across multiple collections, do chained transactions w/ intermediate rollbacks, etc.
120+
But for more complex use cases, you can directly create custom actions with `createOptimisticAction` or custom transactions with `createTransaction`. This lets you do things such as do transactions with multiple mutations across multiple collections, do chained transactions w/ intermediate rollbacks, etc.
121121

122122
For example, in the following code, the mutationFn first sends the write to the server using `await api.todos.update(updatedTodo)` and then calls `await collection.refetch()` to trigger a re-fetch of the collection contents using TanStack Query. When this second await resolves, the collection is up-to-date with the latest changes and the optimistic state is safely discarded.
123123

124124
```ts
125-
const updateTodo = useOptimisticMutation({
125+
const updateTodo = createOptimisticAction<{id: string}>({
126+
onMutate,
126127
mutationFn: async ({ transaction }) => {
127128
const { collection, modified: updatedTodo } = transaction.mutations[0]
128129

@@ -384,55 +385,83 @@ const mutationFn: MutationFn = async ({ transaction }) => {
384385
}
385386
```
386387

387-
#### `useOptimisticMutation`
388+
#### `createOptimisticAction`
388389

389-
Use the `useOptimisticMutation` hook with your `mutationFn` to create a mutator that you can use to mutate data in your components:
390+
Use `createOptimisticAction` with your `mutationFn` and `onMutate` functions to create an action that you can use to mutate data in your components in fully custom ways:
390391

391392
```tsx
392-
import { useOptimisticMutation } from '@tanstack/react-db'
393+
import { createOptimisticAction } from '@tanstack/react-db'
394+
395+
// Create the `addTodo` action, passing in your `mutationFn` and `onMutate`.
396+
const addTodo = createOptimisticAction<string>({
397+
onMutate: (text) => {
398+
// Instantly applies the local optimistic state.
399+
todoCollection.insert({
400+
id: uuid(),
401+
text,
402+
completed: false
403+
})
404+
},
405+
mutationFn: async (text) => {
406+
// Persist the todo to your backend
407+
const response = await fetch('/api/todos', {
408+
method: 'POST',
409+
body: JSON.stringify({ text, completed: false }),
410+
})
411+
return response.json()
412+
}
413+
})
393414
394415
const Todo = () => {
395-
// Create the `addTodo` mutator, passing in your `mutationFn`.
396-
const addTodo = useOptimisticMutation({ mutationFn })
397-
398416
const handleClick = () => {
399-
// Triggers the mutationFn
400-
addTodo.mutate(() =>
401-
// Instantly applies the local optimistic state.
402-
todoCollection.insert({
403-
id: uuid(),
404-
text: '🔥 Make app faster',
405-
completed: false
406-
})
407-
)
417+
// Triggers the onMutate and then the mutationFn
418+
addTodo('🔥 Make app faster')
408419
}
409420
410421
return <Button onClick={ handleClick } />
411422
}
412423
```
413424

414-
Transaction lifecycles can be manually controlled:
425+
## Manual Transactions
426+
427+
By manually creating transactions, you can fully control their lifecycles and behaviors. `createOptimisticAction` is a ~25 line
428+
function which implements a common transaction pattern. Feel free to invent your own patterns!
429+
430+
431+
Here's one way you could use transactions.
415432

416433
```ts
417-
const addTodo = useOptimisticMutation({ mutationFn })
418-
const tx = addTodo.createTransaction()
434+
import { createTransaction } from "@tanstack/react-db"
419435
420-
tx.mutate(() => {}
436+
const addTodoTx = createTransaction({
437+
autoCommit: false,
438+
mutationFn: async ({ transaction }) => {
439+
// Persist data to backend
440+
await Promise.all(transaction.mutations.map(mutation => {
441+
return await api.saveTodo(mutation.modified)
442+
})
443+
},
444+
})
445+
446+
// Apply first change
447+
addTodoTx.mutate(() => todoCollection.insert({ id: '1', text: 'First todo', completed: false }))
421448
422449
// user reviews change
423450
424-
// Another mutation
425-
tx.mutate(() => {}
451+
// Apply another change
452+
addTodoTx.mutate(() => todoCollection.insert({ id: '2', text: 'Second todo', completed: false }))
426453
427-
// Mutation is approved
428-
tx.commit()
454+
// User decides to save and we call .commit() and the mutations are persisted to the backend.
455+
addTodoTx.commit()
429456
```
430457

458+
## Transaction lifecycle
459+
431460
Transactions progress through the following states:
432461

433462
1. `pending`: Initial state when a transaction is created and optimistic mutations can be applied
434463
2. `persisting`: Transaction is being persisted to the backend
435-
3. `completed`: Transaction has been successfully persisted
464+
3. `completed`: Transaction has been successfully persisted and any backend changes have been synced back.
436465
4. `failed`: An error was thrown while persisting or syncing back the Transaction
437466

438467
#### Write operations
@@ -569,10 +598,7 @@ This pattern allows you to extend an existing TanStack Query application, or any
569598

570599
One of the most powerful ways of using TanStack DB is with a sync engine, for a fully local-first experience with real-time sync. This allows you to incrementally adopt sync into an existing app, whilst still handling writes with your existing API.
571600

572-
Here, we illustrate this pattern:
573-
574-
- using [ElectricSQL](https://electric-sql.com) as the sync engine; and
575-
- Instead of sending mutations to unique endpoints, we show an example here of how to POST mutations to a generic `/ingest/mutations` endpoint
601+
Here, we illustrate this pattern using [ElectricSQL](https://electric-sql.com) as the sync engine.
576602

577603
```tsx
578604
import type { Collection } from '@tanstack/db'
@@ -592,71 +618,25 @@ export const todoCollection = createCollection(electricCollectionOptions<Todo>({
592618
},
593619
getKey: (item) => item.id,
594620
schema: todoSchema
595-
}))
596-
597-
// Define a generic `mutationFn` that handles all mutations and
598-
// POSTs them to a backend ingestion endpoint.
599-
const mutationFn: MutationFn = async ({ transaction }) => {
600-
const payload = transaction.mutations.map((mutation: PendingMutation) => {
601-
const { collection: _, ...rest } = mutation
602-
603-
return rest
604-
})
605-
606-
const response = await fetch('/ingest/mutations', {
607-
method: 'POST',
608-
headers: {
609-
'Content-Type': 'application/json',
610-
},
611-
body: JSON.stringify(payload)
612-
})
621+
onInsert: ({ transaction }) => {
622+
const response = await api.todos.create(transaction.mutations[0].modified)
613623
614-
if (!response.ok) {
615-
// Throwing an error will rollback the optimistic state.
616-
throw new Error(`HTTP Error: ${response.status}`)
624+
return { txid: response.txid}
617625
}
626+
// You can also implement onUpdate, onDelete as needed.
627+
}))
618628
619-
const result = await response.json()
620-
621-
// Wait for the transaction to be synced back from the server
622-
// before discarding the optimistic state.
623-
const collection: Collection = transaction.mutations[0].collection
624-
625-
// Here we use an ElectricSQL feature to monitor the incoming sync
626-
// stream for the database transaction ID that the writes we made
627-
// were applied under. When this syncs through, we can safely
628-
// discard the local optimistic state.
629-
await collection.awaitTxId(result.txid)
630-
}
631-
632-
// We can now use the same mutationFn for any local write operations.
633629
const AddTodo = () => {
634-
const addTodo = useOptimisticMutation({ mutationFn })
635-
636630
return (
637631
<Button
638632
onClick={() =>
639-
addTodo.mutate(() =>
640-
todoCollection.insert({
641-
id: uuid(),
642-
text: "🔥 Make app faster",
643-
completed: false
644-
})
645-
)
633+
todoCollection.insert({ text: "🔥 Make app faster" })
646634
}
647635
/>
648636
)
649637
}
650638
```
651639

652-
The key requirements for the server in this case are:
653-
654-
1. to be able to parse and ingest the payload format
655-
2. to return the database transaction ID that the changes were applied under; this then allows the mutationFn to monitor the replication stream for that `txid`, at which point the local optimistic state is discarded
656-
657-
> [!TIP]
658-
> One reference implementation of a backend designed to ingest changes from TanStack DB is the [`Phoenix.Sync.Writer`](https://hexdocs.pm/phoenix_sync/Phoenix.Sync.Writer.html) module from the [Phoenix.Sync](https://hexdocs.pm/phoenix_sync) library for the [Phoenix web framework](https://www.phoenixframework.org).
659-
660640
## More info
661641

662642
If you have questions / need help using TanStack DB, let us know on the Discord or start a GitHub discussion:

packages/db/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from "./errors"
77
export * from "./utils"
88
export * from "./proxy"
99
export * from "./query/index.js"
10+
export * from "./optimistic-action"
1011

1112
// Re-export some stuff explicitly to ensure the type & value is exported
1213
export type { Collection } from "./collection"
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { createTransaction } from "./transactions"
2+
import type { CreateOptimisticActionsOptions, Transaction } from "./types"
3+
4+
/**
5+
* Creates an optimistic action function that applies local optimistic updates immediately
6+
* before executing the actual mutation on the server.
7+
*
8+
* This pattern allows for responsive UI updates while the actual mutation is in progress.
9+
* The optimistic update is applied via the `onMutate` callback, and the server mutation
10+
* is executed via the `mutationFn`.
11+
*
12+
* @example
13+
* ```ts
14+
* const addTodo = createOptimisticAction<string>({
15+
* onMutate: (text) => {
16+
* // Instantly applies local optimistic state
17+
* todoCollection.insert({
18+
* id: uuid(),
19+
* text,
20+
* completed: false
21+
* })
22+
* },
23+
* mutationFn: async (text, params) => {
24+
* // Persist the todo to your backend
25+
* const response = await fetch('/api/todos', {
26+
* method: 'POST',
27+
* body: JSON.stringify({ text, completed: false }),
28+
* })
29+
* return response.json()
30+
* }
31+
* })
32+
*
33+
* // Usage
34+
* const transaction = addTodo('New Todo Item')
35+
* ```
36+
*
37+
* @template TVariables - The type of variables that will be passed to the action function
38+
* @param options - Configuration options for the optimistic action
39+
* @returns A function that accepts variables of type TVariables and returns a Transaction
40+
*/
41+
export function createOptimisticAction<TVariables = unknown>(
42+
options: CreateOptimisticActionsOptions<TVariables>
43+
) {
44+
const { mutationFn, onMutate, ...config } = options
45+
46+
return (variables: TVariables): Transaction => {
47+
// Create transaction with the original config
48+
const transaction = createTransaction({
49+
...config,
50+
// Wire the mutationFn to use the provided variables
51+
mutationFn: async (params) => {
52+
return await mutationFn(variables, params)
53+
},
54+
})
55+
56+
// Execute the transaction. The mutationFn is called once mutate()
57+
// is finished.
58+
transaction.mutate(() => {
59+
// Call onMutate with variables to apply optimistic updates
60+
onMutate(variables)
61+
})
62+
63+
return transaction
64+
}
65+
}

packages/db/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ export interface TransactionConfig<T extends object = Record<string, unknown>> {
111111
metadata?: Record<string, unknown>
112112
}
113113

114+
/**
115+
* Options for the createOptimisticAction helper
116+
*/
117+
export interface CreateOptimisticActionsOptions<TVars = unknown>
118+
extends Omit<TransactionConfig, `mutationFn`> {
119+
/** Function to apply optimistic updates locally before the mutation completes */
120+
onMutate: (vars: TVars) => void
121+
/** Function to execute the mutation on the server */
122+
mutationFn: (vars: TVars, params: MutationFnParams) => Promise<any>
123+
}
124+
114125
export type { Transaction }
115126

116127
type Value<TExtensions = never> =

0 commit comments

Comments
 (0)