Chapter 3: Transaction #
Welcome back! In the previous chapter,
YType, we saw how shared types like Y.Map and Y.Text capture every change. But we didn’t talk about when those changes are announced. If you change two things at once, should your app update twice? No. Yjs uses a Transaction to bundle changes together into one neat package.
What problem does Transaction solve? #
Imagine you’re building a collaborative profile card. A Y.Map holds the profile:
const profile = doc.getMap('profile')
profile.set('name', 'Ada')
profile.set('title', 'Engineer')
If you run those two lines one after another, Yjs could theoretically notify your app twice: first with a card that says “Ada” and no title, then with the full card. That would make the screen flicker. It would also send two network messages. Instead, Yjs wraps both changes in a single transaction. Observers are told only once, after both changes are done. The result feels like the profile changed from “old Ada” to “new Ada” in one single moment.
Key concept: a Transaction is a batch #
Think of a transaction as a mixing bowl in a kitchen. You don’t serve flour, eggs, and milk separately. You mix them all in the bowl first, then you serve the cake. Similarly, a transaction collects related changes, and only when the batch is complete does Yjs announce the result.
Here’s what a transaction gives you:
- Atomicity: From the outside, the changes happen at the same time. You never see a half-finished state.
- One update message: When the transaction finishes, it produces a single
Uint8Arrayupdate that describes all the changes. - A simple
originlabel: You can attach a reason to the batch, like'local-user'or'remote-change'.
How to use a Transaction #
You don’t usually create a Transaction object directly. You use doc.transact(fn, origin) from the Doc.
Step 1: Group multiple changes #
Let’s update two fields at once:
doc.transact(() => {
profile.set('name', 'Grace')
profile.set('title', 'Mathematician')
})
Both set calls happen in the same batch.
Step 2: Observe what happened #
Let’s attach an observer to see how many times the profile changes:
let changes = 0
profile.observe(() => {
changes++
console.log('change', changes, profile.toJSON())
})
Now run a grouped transaction with two set calls:
doc.transact(() => {
profile.set('name', 'Ada')
profile.set('title', 'Engineer')
})
You will see this output:
change 1 { name: 'Ada', title: 'Engineer' }
The observer ran once, not twice. The two changes were announced together.
Step 3: See what happens without a transaction #
Now reset the counter and make two separate changes:
changes = 0
profile.set('name', 'Katherine')
profile.set('title', 'Physicist')
This time you’ll see:
change 1 { name: 'Katherine', title: 'Engineer' }
change 2 { name: 'Katherine', title: 'Physicist' }
Observers were notified twice. Each set call is its own tiny transaction. That’s the difference: without a transaction, each change is announced separately; with a transaction, they’re announced together.
Step 4: Add an origin #
You can pass an origin as the second argument to doc.transact. This is a label that explains where the changes came from:
doc.transact(() => {
profile.set('name', 'Ada')
}, 'my-app')
You can read it in the update listener:
doc.on('update', (update, origin) => {
console.log(origin) // 'my-app'
})
The origin is useful when you want to ignore changes that you made yourself. For example, in a chat app, you might only fetch new content when origin is a remote peer.
What’s under the hood? #
When you call doc.transact, several things happen in order. Let’s walk through them with a small example.
Suppose you run:
doc.transact(() => {
profile.set('name', 'Ada')
profile.set('title', 'Engineer')
})
Here is a picture of the process:
Let’s break it down:
- You call
doc.transact(fn). - The
Docchecks if a transaction is already active. If not, it creates a newTransaction. - Your function runs.
- Each time you call
profile.set, Yjs creates a small struct called an Item and places it in the StructStore, the warehouse where all data lives. - After your function returns, the transaction calls
finish(). finish()encodes the new structs into one update, emits theupdateevent, and tells all observers about the changes.- The transaction is closed.
Simplified code from Doc #
Here’s a simplified version of doc.transact based on the real code in src/utils/Doc.js:
// simplified from src/utils/Doc.js
transact(f, origin) {
if (this._transaction) {
return f() // already inside a transaction
}
const t = new Transaction(this, origin)
try {
return f()
} finally {
t.finish()
}
}
Notice the if (this._transaction) check. It means that if you’re already inside a transaction, a nested doc.transact doesn’t create a new one. It just runs your function inside the existing transaction. This is useful when different parts of your app each call transact, but they end up sharing one batch.
Simplified code from Transaction #
The real Transaction class lives in src/utils/Transaction.js. Here is a very simplified version:
// simplified from src/utils/Transaction.js
class Transaction {
constructor(doc, origin) {
this.doc = doc
this.origin = origin
doc._transaction = this
}
finish() {
// collect all new structs and encode them into one update
const update = this.encodeNewStructs()
// tell the Doc about the update
this.doc.emit('update', [update, this.origin, this.doc])
// close the transaction
this.doc._transaction = null
// notify all shared type observers
for (const event of this.changedEvents) {
event.target.emit('change', event)
}
}
}
We left out a lot of important details, but this is the main idea. The transaction is the thing that:
- keeps a reference to the
Doc - remembers the
origin - tracks what structs were added or removed
- produces a single update
- notifies all observers
The encodeNewStructs() function in the real code is much more clever, but for now you can think of it as “turn all the changes from this transaction into one compact binary message.”
What about applying remote updates? #
Remote updates are also applied inside a transaction. When you call:
Y.applyUpdate(doc, update)
Yjs opens a transaction, applies all the structs from the incoming update, and then finishes. This means:
- Observers don’t see partially applied remote data.
- The
updateevent fires once with a label you can use.
So even if update contains 200 changes from a friend, your app sees one clean batch. This is one of the reasons Yjs feels smooth during sync.
Nested transactions are your friend #
Sometimes you might have helpers that each call doc.transact. Here is an example where a helper is called inside an outer transaction:
function setFullName(doc, profile, name) {
doc.transact(() => {
const parts = name.split(' ')
profile.set('firstName', parts[0])
profile.set('lastName', parts[1])
})
}
doc.transact(() => {
setFullName(doc, profile, 'Grace Hopper')
profile.set('rank', 'Rear Admiral')
})
Only one transaction is used for all three set calls. The inner transact sees that doc._transaction already exists, so it simply runs inside the outer transaction. The result is one update event with all three changes.
Conclusion #
Transactions are the quiet worker behind every Yjs change.
- A transaction is a batch of changes applied atomically.
- Use
doc.transact(fn, origin)to group multiple local changes. - Shared type observers and
doc.on('update')fire only after the transaction finishes. - Remote updates are applied inside a transaction too.
- Nested
transactcalls merge into the current transaction.
Now that you know how changes are grouped in a transaction, the next natural question is: how do those changes get packed into a message? That’s exactly what Update Encoding / Decoding is about.
Generated by AI Codebase Knowledge Builder