Transactions
Transactions let you update multiple atoms at once. Subscribers and selectors only re-evaluate after all updates are applied — never in an intermediate state.
The problem
Without transactions, each set call immediately notifies subscribers:
import { atom, selector, store } from "valdres"
const firstName = atom("John")
const lastName = atom("Doe")
const fullName = selector(get => `${get(firstName)} ${get(lastName)}`)
const myStore = store()
// Two separate updates — subscribers fire twice
// After first set: fullName = "Jane Doe"
// After second set: fullName = "Jane Smith"
myStore.set(firstName, "Jane")
myStore.set(lastName, "Smith")
If a component is subscribed to fullName, it briefly shows "Jane Doe" before settling on "Jane Smith". That intermediate state may cause flickers or unnecessary work.
The solution
Wrap related updates in store.txn():
myStore.txn(({ set }) => {
set(firstName, "Jane")
set(lastName, "Smith")
// Nothing fires until here
})
// Now subscribers fire once with fullName = "Jane Smith"
Synchronous callbacks
Transaction callbacks must be synchronous. Returning a Promise or other
thenable throws and aborts the automatic commit, because writes after an
await cannot be part of the same atomic commit. Perform asynchronous work
before the transaction, then apply its result synchronously:
const user = await fetchUser()
myStore.txn(({ set }) => {
set(userAtom, user)
set(statusAtom, "ready")
})
Atomic lifecycle
Valdres owns the transaction lifecycle. The callback receives state operations
such as get, set, and reset, but no commit() method or backing store
data. Returning successfully commits once; throwing discards every staged write:
myStore.txn(({ set }) => {
set(statusAtom, "saving")
throw new Error("save failed")
})
// statusAtom was not changed
If an operation intentionally needs multiple publish points, use separate
store.txn() calls. Each call is an explicit atomic boundary.
Updater functions
Inside a transaction, set works the same as outside — you can pass values or updater functions:
const countA = atom(0)
const countB = atom(0)
myStore.txn(({ set }) => {
set(countA, prev => prev + 1)
set(countB, prev => prev + 10)
})
When to use transactions
Use transactions when:
- Updating multiple related atoms that feed into the same selector
- Moving items between collections (remove from one, add to another)
- Resetting a form (clear all fields at once)
- Any operation where intermediate state would be wrong or confusing
You don't need transactions when:
- Updating a single atom
- Updates are unrelated and independent
- Intermediate states are acceptable
Real-world example: drag and drop
When an item is dragged from one list to another, you need to remove it from the source and add it to the target atomically:
import { atom, atomFamily, store } from "valdres"
const listItemsAtom = atomFamily() // listId → itemId[]
const itemAtom = atomFamily() // itemId → item data
const myStore = store()
function moveItem(itemId, fromListId, toListId) {
myStore.txn(({ set }) => {
set(listItemsAtom(fromListId), ids =>
ids.filter(id => id !== itemId)
)
set(listItemsAtom(toListId), ids =>
[...ids, itemId]
)
})
// Components subscribed to either list update once
// The item is never "missing" from both lists
}
Performance
Transactions aren't just about correctness — they're a performance optimization. Without them, a selector depending on 10 atoms would recompute 10 times during a bulk update. With a transaction, it recomputes once.
const atoms = Array.from({ length: 100 }, () => atom(0))
const sum = selector(get => atoms.reduce((acc, a) => acc + get(a), 0))
// Without txn: sum recomputes 100 times
atoms.forEach(a => myStore.set(a, 1))
// With txn: sum recomputes once
myStore.txn(({ set }) => {
atoms.forEach(a => set(a, 1))
})