-
Notifications
You must be signed in to change notification settings - Fork 6
RFC: Explicit Cross-Collection Transactions for TanStack Optimistic #29
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
Comments
Some more thoughts — I'm thinking now that transactions have to handle persistence themselves. They can re-use other API calls to persist parts in parallel but I think it's a pretty natural expectation that an explicit transaction is written together and it'd be odd if an explicit transaction was persisted in part across several collection mutationFns. Also I've been feeling annoyed by collections needing to have IDs. When playing around with things, I don't like having to come up with IDs and it's in practice pretty hard to reference the right collection as you might e.g. have tons of little collections. Something like this is feeling nice: const transaction = useTransaction({ mutationFn });
transaction.mutate(() => {
// Both inserts are automatically tied to the transaction
usersCollection.insert({ id: 'bob-id', name: 'Bob' });
teamMembersCollection.insert({ userId: 'bob-id', teamId: 'team-1' });
});
// Call .mutate as many times as you'd like.
// Drop optimistic changes and close the transaction
// transaction.rollback()
transaction.commit() We can use a global registry of entering/leaving mutate functions in order to connect a collections mutation to the transaction's mutation. |
Ok, worked with Windsurf and wrote a formal RFC RFC: Explicit Cross-Collection Transactions for TanStack OptimisticSummaryThis RFC proposes adding explicit transaction support to TanStack Optimistic, allowing developers to group mutations across multiple collections into a single transaction with explicit lifecycle control. This addresses real-world use cases where related data needs to be updated atomically across different collections, such as adding a user to a team which requires updating both the users collection and the team_members collection. BackgroundTanStack Optimistic currently supports implicit collection-level transactions, where mutations to a collection within a single tick are grouped into a transaction and processed by the collection's mutationFn. Each collection has its own TransactionManager that handles the lifecycle of these transactions. The current transaction system has several key components:
While this works well for single-collection operations, it doesn't address scenarios where related data needs to be updated across multiple collections as part of a single logical operation. Transaction management is a common pattern across both server-side and client-side libraries. Server-side libraries like Knex, Prisma, and Drizzle ORM provide explicit transaction APIs that allow grouping multiple operations across tables into a single atomic unit. Client-side libraries like Dexie.js (for IndexedDB) and Replicache also provide transaction capabilities for local data stores. These libraries demonstrate the value of explicit transaction control for ensuring data consistency across related entities. ProblemThe current implicit collection-level transaction system has several limitations:
These limitations make it difficult to implement certain common patterns in applications, forcing developers to create workarounds or avoid using TanStack Optimistic for these scenarios. ProposalWe propose adding explicit transaction support to TanStack Optimistic through a new API Designconst transaction = useTransaction({
mutationFn: async ({ transaction, collections }) => {
// Custom logic to persist all changes in a single API call
await api.createUserWithTeam({
user: transaction.mutations.find(m => m.collection === 'users'),
teamMember: transaction.mutations.find(m => m.collection === 'teamMembers')
});
}
});
// Execute mutations within the transaction context
transaction.mutate(() => {
// Both inserts are automatically tied to the transaction
usersCollection.insert({ id: 'bob-id', name: 'Bob' });
teamMembersCollection.insert({ userId: 'bob-id', teamId: 'team-1' });
});
// Call .mutate as many times as you'd like to accumulate changes
transaction.mutate(() => {
usersCollection.update({ id: 'bob-id', name: 'Bob Smith' });
});
// Explicitly commit the transaction when ready
transaction.commit();
// Or roll back optimistic changes and cancel the transaction
// transaction.rollback(); Implementation Details
Transaction Behavior
Future Enhancements (Not in Initial Implementation)
Definition of SuccessThe explicit transaction feature will be considered successful if it:
|
Thinking about this some more — Something like: const addTodo = useOptimisticMutation({ mutationFn })
// Call as many times as you want.
addTodo.mutate(() => {
todosCollection.insert(data)
}) The above transaction would immediately commit similar to the collection mutators. But is different as it allows cross-collection mutations & a custom mutationFn. If you want to control the transaction lifespan you explicitly create a transaction: const multiStepForm = useOptimisticMutation({ mutationFn })
const multiStepTransaction = multiStepForm.createTransaction({ id: `optional id for tooling` })
// Add first set of changes
multiStepTransaction.mutate(() => {
// Modify a few collections
})
// Add second set of changes
multiStepTransaction.mutate(() => {
// Modify a few collections
})
await multiStepTransaction.commit() |
Hmm now I'm wondering if we only support You still use the collection mutators but transactions are always explicit. Also relevant is @samwillis' exploration of creating query collections i.e. collections which are derived from a query over other collection(s). As for these it'd definitely not make sense to have their own mutationFn but mutating them directly as part of a transaction still makes sense as you could trace from that optimistic change back to what backend change is needed. |
I like how this is becoming more composable, and I wander if we can go a step further and pull the sync out of the collection too? So we end up with three things:
Disconnecting sync from the collections enables alternative collections that have an alternative backend. We can eventually have those that are locally stored and not synced, or ephemeral in-memory collections for application state. |
@samwillis it seems the question is do we want to set who can write into a collection at the collection definition time or let anyone write to it whenever (kinda like early vs. late binding). So yeah, if a single "sync" instance could drive a bunch of collections — the late binding between sync & the collection makes sense. Also you could imagine a collection which is initially populated by some data cached in indexeddb and then later perhaps a sync tool is connected to it. Ok, yeah, makes sense to me! |
Updated the issue body with the latest design. |
Summary
This RFC proposes elevating transactions to first-class citizens in TanStack Optimistic by introducing explicit transaction support through a new mutation factory API. This change addresses limitations in the current implementation that prevent developers from implementing common patterns like cross-collection updates and multi-step operations. By making transactions explicit and providing direct control over their lifecycle, we enable more complex workflows while maintaining the optimistic UI experience that makes the library valuable. The proposed API is designed to be intuitive and flexible, supporting both simple cross-collection operations and complex multi-step workflows.
Background
TanStack Optimistic is a library for building optimistic UI experiences, where user interface updates happen immediately while data changes are persisted asynchronously in the background. This approach provides a responsive user experience while ensuring data consistency.
The current architecture of TanStack Optimistic includes several key components:
Collections: Represent sets of data with the same structure, similar to database tables. Each collection has its own state management and transaction handling.
TransactionManager: Each collection has its own transaction manager that handles the lifecycle of transactions for that collection. The transaction manager is responsible for creating, persisting, and tracking transactions.
Transactions: Currently an internal implementation detail, transactions represent groups of mutations (inserts, updates, deletes) that are processed together. Transactions have a lifecycle (pending, persisting, completed, failed) and are used to track the status of optimistic updates.
MutationFn: Each collection can define a mutation function that is responsible for persisting changes to the backend. This function is called when a transaction is ready to be persisted.
Sync: Collections also have a sync mechanism that handles incoming changes from the backend, ensuring that the local state stays in sync with the server state.
In the current implementation, transactions are created implicitly when mutation operations (insert, update, delete) are called on a collection. Mutations that occur within the same event loop tick are grouped into a single transaction. The transaction is then processed by the collection's transaction manager, which calls the collection's mutation function to persist the changes.
This approach works well for simple cases where mutations are isolated to a single collection. However, it doesn't address more complex scenarios where related data needs to be updated across multiple collections as part of a single logical operation.
Other libraries and frameworks have addressed similar challenges:
These examples demonstrate the value of explicit transaction control for ensuring data consistency across related entities and providing a better developer experience for complex workflows.
Problem
The current transaction system in TanStack Optimistic has several limitations that prevent developers from implementing common patterns found in real-world applications:
Transactions are hidden implementation details: Transactions exist in the library but are not exposed as first-class objects that developers can interact with directly. This limits the ability to build more complex workflows on top of the transaction system.
No cross-collection transactions: Each collection manages its own transactions independently, making it impossible to atomically update related data across multiple collections. This is a common requirement in real-world applications, such as adding a user to a team which requires updating both the users collection and the team_members collection.
Mutation functions are tied to collections: The
mutationFn
is defined at the collection level, which doesn't accommodate scenarios where a single API endpoint handles changes to multiple related entities. For example, a POST to/api/users
might create both a user record and team membership records.No explicit transaction lifecycle control: Developers cannot explicitly control when a transaction is created, accumulated, committed, or rolled back. The transaction lifecycle is managed automatically by collections, with mutations within a single tick being grouped together.
Limited flexibility for multi-step operations: Some workflows require accumulating changes over time before committing them, such as multi-step forms or wizard interfaces. The current system doesn't provide a way to build up a transaction across multiple user interactions.
These limitations force developers to create workarounds or avoid using TanStack Optimistic for more complex scenarios, reducing the library's utility in real-world applications.
Proposal
We propose making transactions first-class citizens in TanStack Optimistic by introducing explicit transaction support through a new mutation factory API. This will allow developers to create, control, and persist transactions that span multiple collections.
API Design
The new API will provide two main patterns for working with transactions:
Implementation Details
Transaction Registry: Create a global transaction registry to track active transactions. When a transaction's
mutate
method is called, it registers itself as the active transaction for the duration of the callback.Collection Integration:
mutationFn
from the Collection API.Transaction Manager Refactoring: Refactor the existing
TransactionManager
to work with external transactions rather than being tied to a specific collection. This will involve:Mutation Factory: Implement the
useOptimisticMutation
hook that creates mutation functions with optional transaction creation capabilities.Transaction Object: Create a new
Transaction
class with methods for:mutate(callback)
: Execute a function where collection operations are automatically linked to the transactioncommit()
: Finalize the transaction and persist changesrollback()
: Discard optimistic changes and cancel the transactiongetState()
: Get the current state of the transaction (pending, committing, completed, failed)Dependency Tracking: Implement tracking of transaction dependencies to ensure that if a transaction fails, any dependent transactions are also rolled back.
Transaction Behavior
Building on Optimistic Changes: Each new transaction will build on top of existing optimistic changes from other active transactions, as well as synced changes from the backend.
Concurrent Transactions: Multiple transactions can be active simultaneously, with the understanding that conflicts will be resolved by the backend or through defensive programming. The library will not enforce strict isolation between transactions.
Error Handling: Transaction errors will be exposed through the promise returned by
transaction.commit()
. If a transaction fails, its optimistic changes will be rolled back, along with any dependent transactions.Collection Awareness: Collections will be informed when they are part of a transaction, but they will not own or manage the transaction lifecycle.
Mutation Function: The transaction's
mutationFn
will receive an array of all mutations across all collections involved in the transaction, allowing for flexible backend integration.Definition of Success
The explicit transaction feature will be considered successful if it:
Makes transactions first-class citizens: Developers can create, control, and interact with transactions directly, building more complex workflows on top of the transaction system.
Enables cross-collection operations: Developers can atomically update related data across multiple collections as part of a single transaction.
Provides flexible mutation persistence: The transaction's mutation function can handle persisting changes to multiple collections in the most appropriate way for the backend, whether that's a single API call or multiple coordinated calls.
Supports explicit lifecycle control: Developers can explicitly control when a transaction is created, accumulated, committed, or rolled back.
Enables multi-step operations: Developers can build up a transaction across multiple user interactions, such as multi-step forms or wizard interfaces.
Provides a clean, intuitive API: The API is easy to understand and use, with minimal boilerplate and clear patterns for common use cases.
Passes comprehensive tests: The implementation passes tests for:
.mutate()
accumulating optimistic updates correctlyMaintains performance: The feature does not significantly impact performance compared to the existing transaction system.
Solves real-world use cases: The feature enables developers to implement common patterns like the user-team example without workarounds or compromises.
The text was updated successfully, but these errors were encountered: