Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/solid-pandas-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
"@tanstack/react-db": patch
"@tanstack/vue-db": patch
"@tanstack/db": patch
---

Add createOptimisticAction helper that replaces useOptimisticMutation

An example of converting a `useOptimisticMutation` hook to `createOptimisticAction`. Now all optimistic & server mutation logic are consolidated.

```diff
-import { useOptimisticMutation } from '@tanstack/react-db'
+import { createOptimisticAction } from '@tanstack/react-db'
+
+// Create the `addTodo` action, passing in your `mutationFn` and `onMutate`.
+const addTodo = createOptimisticAction<string>({
+ onMutate: (text) => {
+ // Instantly applies the local optimistic state.
+ todoCollection.insert({
+ id: uuid(),
+ text,
+ completed: false
+ })
+ },
+ mutationFn: async (text) => {
+ // Persist the todo to your backend
+ const response = await fetch('/api/todos', {
+ method: 'POST',
+ body: JSON.stringify({ text, completed: false }),
+ })
+ return response.json()
+ }
+})

const Todo = () => {
- // Create the `addTodo` mutator, passing in your `mutationFn`.
- const addTodo = useOptimisticMutation({ mutationFn })
-
const handleClick = () => {
- // Triggers the mutationFn
- addTodo.mutate(() =>
- // Instantly applies the local optimistic state.
- todoCollection.insert({
- id: uuid(),
- text: '🔥 Make app faster',
- completed: false
- })
- )
+ // Triggers the onMutate and then the mutationFn
+ addTodo('🔥 Make app faster')
}

return <Button onClick={ handleClick } />
}
```
144 changes: 62 additions & 82 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,13 @@ Mutations are based on a `Transaction` primitive.

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

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

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.

```ts
const updateTodo = useOptimisticMutation({
const updateTodo = createOptimisticAction<{id: string}>({
onMutate,
mutationFn: async ({ transaction }) => {
const { collection, modified: updatedTodo } = transaction.mutations[0]

Expand Down Expand Up @@ -384,55 +385,83 @@ const mutationFn: MutationFn = async ({ transaction }) => {
}
```

#### `useOptimisticMutation`
#### `createOptimisticAction`

Use the `useOptimisticMutation` hook with your `mutationFn` to create a mutator that you can use to mutate data in your components:
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:

```tsx
import { useOptimisticMutation } from '@tanstack/react-db'
import { createOptimisticAction } from '@tanstack/react-db'

// Create the `addTodo` action, passing in your `mutationFn` and `onMutate`.
const addTodo = createOptimisticAction<string>({
onMutate: (text) => {
// Instantly applies the local optimistic state.
todoCollection.insert({
id: uuid(),
text,
completed: false
})
},
mutationFn: async (text) => {
// Persist the todo to your backend
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text, completed: false }),
})
return response.json()
}
})

const Todo = () => {
// Create the `addTodo` mutator, passing in your `mutationFn`.
const addTodo = useOptimisticMutation({ mutationFn })

const handleClick = () => {
// Triggers the mutationFn
addTodo.mutate(() =>
// Instantly applies the local optimistic state.
todoCollection.insert({
id: uuid(),
text: '🔥 Make app faster',
completed: false
})
)
// Triggers the onMutate and then the mutationFn
addTodo('🔥 Make app faster')
}

return <Button onClick={ handleClick } />
}
```

Transaction lifecycles can be manually controlled:
## Manual Transactions

By manually creating transactions, you can fully control their lifecycles and behaviors. `createOptimisticAction` is a ~25 line
function which implements a common transaction pattern. Feel free to invent your own patterns!


Here's one way you could use transactions.

```ts
const addTodo = useOptimisticMutation({ mutationFn })
const tx = addTodo.createTransaction()
import { createTransaction } from "@tanstack/react-db"

tx.mutate(() => {}
const addTodoTx = createTransaction({
autoCommit: false,
mutationFn: async ({ transaction }) => {
// Persist data to backend
await Promise.all(transaction.mutations.map(mutation => {
return await api.saveTodo(mutation.modified)
})
},
})

// Apply first change
addTodoTx.mutate(() => todoCollection.insert({ id: '1', text: 'First todo', completed: false }))

// user reviews change

// Another mutation
tx.mutate(() => {}
// Apply another change
addTodoTx.mutate(() => todoCollection.insert({ id: '2', text: 'Second todo', completed: false }))

// Mutation is approved
tx.commit()
// User decides to save and we call .commit() and the mutations are persisted to the backend.
addTodoTx.commit()
```

## Transaction lifecycle

Transactions progress through the following states:

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

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

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.

Here, we illustrate this pattern:

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

```tsx
import type { Collection } from '@tanstack/db'
Expand All @@ -592,71 +618,25 @@ export const todoCollection = createCollection(electricCollectionOptions<Todo>({
},
getKey: (item) => item.id,
schema: todoSchema
}))

// Define a generic `mutationFn` that handles all mutations and
// POSTs them to a backend ingestion endpoint.
const mutationFn: MutationFn = async ({ transaction }) => {
const payload = transaction.mutations.map((mutation: PendingMutation) => {
const { collection: _, ...rest } = mutation

return rest
})

const response = await fetch('/ingest/mutations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
})
onInsert: ({ transaction }) => {
const response = await api.todos.create(transaction.mutations[0].modified)

if (!response.ok) {
// Throwing an error will rollback the optimistic state.
throw new Error(`HTTP Error: ${response.status}`)
return { txid: response.txid}
}
// You can also implement onUpdate, onDelete as needed.
}))

const result = await response.json()

// Wait for the transaction to be synced back from the server
// before discarding the optimistic state.
const collection: Collection = transaction.mutations[0].collection

// Here we use an ElectricSQL feature to monitor the incoming sync
// stream for the database transaction ID that the writes we made
// were applied under. When this syncs through, we can safely
// discard the local optimistic state.
await collection.awaitTxId(result.txid)
}

// We can now use the same mutationFn for any local write operations.
const AddTodo = () => {
const addTodo = useOptimisticMutation({ mutationFn })

return (
<Button
onClick={() =>
addTodo.mutate(() =>
todoCollection.insert({
id: uuid(),
text: "🔥 Make app faster",
completed: false
})
)
todoCollection.insert({ text: "🔥 Make app faster" })
}
/>
)
}
```

The key requirements for the server in this case are:

1. to be able to parse and ingest the payload format
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

> [!TIP]
> 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).

## More info

If you have questions / need help using TanStack DB, let us know on the Discord or start a GitHub discussion:
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./errors"
export * from "./utils"
export * from "./proxy"
export * from "./query/index.js"
export * from "./optimistic-action"

// Re-export some stuff explicitly to ensure the type & value is exported
export type { Collection } from "./collection"
65 changes: 65 additions & 0 deletions packages/db/src/optimistic-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createTransaction } from "./transactions"
import type { CreateOptimisticActionsOptions, Transaction } from "./types"

/**
* Creates an optimistic action function that applies local optimistic updates immediately
* before executing the actual mutation on the server.
*
* This pattern allows for responsive UI updates while the actual mutation is in progress.
* The optimistic update is applied via the `onMutate` callback, and the server mutation
* is executed via the `mutationFn`.
*
* @example
* ```ts
* const addTodo = createOptimisticAction<string>({
* onMutate: (text) => {
* // Instantly applies local optimistic state
* todoCollection.insert({
* id: uuid(),
* text,
* completed: false
* })
* },
* mutationFn: async (text, params) => {
* // Persist the todo to your backend
* const response = await fetch('/api/todos', {
* method: 'POST',
* body: JSON.stringify({ text, completed: false }),
* })
* return response.json()
* }
* })
*
* // Usage
* const transaction = addTodo('New Todo Item')
* ```
*
* @template TVariables - The type of variables that will be passed to the action function
* @param options - Configuration options for the optimistic action
* @returns A function that accepts variables of type TVariables and returns a Transaction
*/
export function createOptimisticAction<TVariables = unknown>(
options: CreateOptimisticActionsOptions<TVariables>
) {
const { mutationFn, onMutate, ...config } = options

return (variables: TVariables): Transaction => {
// Create transaction with the original config
const transaction = createTransaction({
...config,
// Wire the mutationFn to use the provided variables
mutationFn: async (params) => {
return await mutationFn(variables, params)
},
})

// Execute the transaction. The mutationFn is called once mutate()
// is finished.
transaction.mutate(() => {
// Call onMutate with variables to apply optimistic updates
onMutate(variables)
})

return transaction
}
}
11 changes: 11 additions & 0 deletions packages/db/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ export interface TransactionConfig<T extends object = Record<string, unknown>> {
metadata?: Record<string, unknown>
}

/**
* Options for the createOptimisticAction helper
*/
export interface CreateOptimisticActionsOptions<TVars = unknown>
extends Omit<TransactionConfig, `mutationFn`> {
/** Function to apply optimistic updates locally before the mutation completes */
onMutate: (vars: TVars) => void
/** Function to execute the mutation on the server */
mutationFn: (vars: TVars, params: MutationFnParams) => Promise<any>
}

export type { Transaction }

type Value<TExtensions = never> =
Expand Down
Loading