Transactions Example
Every mutating operation is a transaction. Where no transaction is open, the operation opens its own, so a single write announces once and cannot be observed half-applied. transaction() groups several writes into one transaction.
Demo
The Application state cart from the cookbook: a Group read through view(), with a list of items, a coupon, a pickup flag and a delivery address that is a Group inside the cart's Group.
Total 91.00
Cart is invalid; ValueChangedAction on the cart has fired 0 times
{
"items": [
{
"sku": "m-1",
"name": "Mug",
"price": 12,
"quantity": 2
},
{
"sku": "t-1",
"name": "Teapot",
"price": 35,
"quantity": 1
},
{
"sku": "c-1",
"name": "Coasters",
"price": 8,
"quantity": 4
}
],
"coupon": null,
"pickup": false,
"delivery": {
"street": "",
"city": ""
}
}- One announcement per transaction. The counter counts
ValueChangedActionon the cart. Each write of a quantity announces once. Fill address writes the pickup flag, the street and the city in onetransaction(), and the counter goes up by one. - Rollback. Edit, then cancel sets every quantity to 10 and a coupon, reads the
computedtotal inside the transaction, and callstx.rollback(). The cart is as it was, and the counter does not change. - Sorting through the view. Sort by name calls
sort()on the list's view, which moves the rows themselves; a sort that changes the order announces once. - Access from state. A
watchEffectsets the delivery address to'disabled-null'while the cart is collected in the shop: the output showsdelivery: null, the address is not validated, and the cart is valid with an empty address. Switching back restores the address as it was typed. - Derived state. The total is a
computedover the view and recomputes when a quantity changes.
Source code
<template>
<div class="cart-demo">
<v-card class="mb-4">
<v-card-title>Cart</v-card-title>
<v-card-text>
<v-row v-for="(item, index) in cart.items" :key="index" dense align="center">
<v-col cols="5">{{ item.name }}</v-col>
<v-col cols="3" class="text-right">{{ item.price.toFixed(2) }}</v-col>
<v-col cols="4">
<v-text-field
v-model.number="item.quantity"
type="number"
label="Quantity"
density="compact"
:error-messages="item.$.fields.quantity.errors.map((error) => error.detail)"
hide-details="auto"
></v-text-field>
</v-col>
</v-row>
<p class="text-right mt-2"><strong>Total {{ total.toFixed(2) }}</strong></p>
<v-text-field
:model-value="cart.coupon ?? ''"
label="Coupon"
density="compact"
hide-details
class="mt-4"
@update:model-value="(coupon: string) => (cart.coupon = coupon || null)"
></v-text-field>
<v-switch v-model="cart.pickup" label="Collect in the shop" color="primary" hide-details></v-switch>
<v-row dense>
<v-col cols="12" sm="6">
<v-text-field
v-model="cart.delivery.street"
label="Street"
:disabled="!cart.$.fields.delivery.enabled"
:error-messages="cart.$.fields.delivery.fields.street.errors.map((error) => error.detail)"
hide-details="auto"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="cart.delivery.city"
label="City"
:disabled="!cart.$.fields.delivery.enabled"
:error-messages="cart.$.fields.delivery.fields.city.errors.map((error) => error.detail)"
hide-details="auto"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="flex-wrap">
<v-btn @click="sortByName">Sort by name</v-btn>
<v-btn @click="fillAddress">Fill address</v-btn>
<v-btn @click="editThenCancel">Edit, then cancel</v-btn>
</v-card-actions>
</v-card>
<v-card>
<v-card-title>State</v-card-title>
<v-card-text>
<p>
Cart is {{ cart.$.valid ? 'valid' : 'invalid' }};
ValueChangedAction on the cart has fired {{ announcements }} times
</p>
<p v-if="cancelled">{{ cancelled }}</p>
<pre class="output">{{ JSON.stringify(cart.$.value, null, 2) }}</pre>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue';
import {
Field,
Group,
List,
transaction,
ValueChangedAction,
Validators,
view,
} from '../../src'; // from '@dynamicforms/vue-forms'
function createCart() {
const cart = view(new Group({
items: new List(new Group({
sku: new Field({ value: '' }),
name: new Field({ value: '' }),
price: new Field({ value: 0 }),
quantity: new Field({ value: 1, validators: [new Validators.MinValue(1)] }),
})),
coupon: new Field<string | null>({ value: null }),
pickup: new Field({ value: false }),
delivery: new Group({
street: new Field({ value: '', validators: [new Validators.Required()] }),
city: new Field({ value: '', validators: [new Validators.Required()] }),
}),
}));
// a cart collected in the shop sends no delivery address, and keeps the one typed in
watchEffect(() => {
cart.$.fields.delivery.access = cart.pickup ? 'disabled-null' : 'editable';
});
return cart;
}
const cart = createCart();
cart.items.push(
{ sku: 'm-1', name: 'Mug', price: 12, quantity: 2 },
{ sku: 't-1', name: 'Teapot', price: 35, quantity: 1 },
{ sku: 'c-1', name: 'Coasters', price: 8, quantity: 4 },
);
const total = computed(() => cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0));
// Every announcement of a change below the cart, counted
const announcements = ref(0);
cart.$.registerAction(new ValueChangedAction((field, supr, newValue, oldValue) => {
announcements.value += 1;
return supr(field, newValue, oldValue);
}));
// The rows themselves move: each keeps its element, its errors and its touched
function sortByName() {
cart.items.sort((a, b) => a.name.localeCompare(b.name));
}
// Three writes, one announcement
function fillAddress() {
transaction(() => {
cart.pickup = false;
cart.delivery.street = 'Slovenska cesta 1';
cart.delivery.city = 'Ljubljana';
});
}
// The edit is applied and measured, then rolled back: the cart is as it was and nothing is announced
const cancelled = ref('');
function editThenCancel() {
transaction((tx) => {
cart.items.forEach((item) => (item.quantity = 10));
cart.coupon = 'BULK';
cancelled.value = `With 10 of each item the total would be ${total.value.toFixed(2)}; the edit was rolled back.`;
tx.rollback();
});
}
</script>
<style scoped>
.cart-demo {
margin: 2rem 0;
}
.output {
background-color: #f5f5f5;
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
}
</style>Several writes, one announcement
Without a transaction each write announces separately, and a handler on the form runs once per write. Inside a transaction, the writes are applied as they are made and the announcement happens once, for the net change.
import { Field, Group, ValueChangedAction, transaction } from '@dynamicforms/vue-forms';
const form = new Group({
street: new Field({ value: '' }),
city: new Field({ value: '' }),
postCode: new Field({ value: '' }),
});
const announcements: any[] = [];
form.registerAction(new ValueChangedAction((field, supr, newValue, oldValue) => {
announcements.push(newValue);
return supr(field, newValue, oldValue);
}));
// three writes, three announcements
form.fields.street.value = 'Slovenska cesta 1';
form.fields.city.value = 'Ljubljana';
form.fields.postCode.value = '1000';
announcements.length; // 3
announcements.length = 0;
// the same three writes as one operation
transaction(() => {
form.fields.street.value = 'Trg svobode 5';
form.fields.city.value = 'Maribor';
form.fields.postCode.value = '2000';
});
announcements.length; // 1, for the complete addressA value that returns to its starting value announces nothing. A commit announces the current value against the value at the last announcement; intermediate steps are not announced:
announcements.length = 0;
transaction(() => {
form.fields.city.value = 'Koper';
form.fields.city.value = 'Maribor'; // back to what it held
});
announcements.length; // 0Filling a form from a server response
A payload arrives, several fields are written from it, and the form must be consistent when anything reads it, including the cross-field validators.
import { Field, Group, Validators, transaction } from '@dynamicforms/vue-forms';
const booking = new Group({
from: new Field<string>({ value: '' }),
to: new Field<string>({
value: '',
validators: [
new Validators.CompareTo<string>('from', (to, from) => !from || !to || to >= from, {
code: 'date_order',
detail: 'The end must not precede the start',
}),
],
}),
});
// written one at a time, `to` is validated against a `from` that is not there yet
transaction(() => {
booking.fields.from.value = '2026-08-20';
booking.fields.to.value = '2026-08-24';
});
booking.valid; // true: both dates were written before validity was announcedUndoing an edit
tx.rollback() undoes everything the transaction did and announces nothing. Use it for a cancel button on an editing dialog.
const row = new Group({
name: new Field({ value: 'Ada Lovelace' }),
role: new Field({ value: 'author' }),
});
transaction((tx) => {
row.fields.name.value = 'Grace Hopper';
row.fields.role.value = 'admiral';
if (!userConfirmedTheEdit()) tx.rollback();
});
row.value; // { name: 'Ada Lovelace', role: 'author' } where the edit was cancelledThe handle is valid only until the call returns. Calling rollback() on it later throws a TypeError; it does not roll back a transaction that is open at that time.
A throw puts everything back
When a handler throws, the form is restored to its state before the transaction, and the error is rethrown:
const form = new Group({ a: new Field({ value: 'first' }), b: new Field({ value: 'second' }) });
form.fields.b.registerAction(new ValueChangedAction(() => {
throw new Error('the server rejected it');
}));
try {
transaction(() => {
form.fields.a.value = 'changed';
form.fields.b.value = 'changed'; // the handler throws here
});
} catch (error) {
form.fields.a.value; // 'first': the whole transaction is rolled back, not only the failing write
}A rollback does not undo side effects: a server call a handler made during the transaction has already happened. Rollback restores only the form's state.
Rows, and what a rollback does with them
A List is restored structurally: rows the transaction created are dropped, and rows it removed are put back at the positions they held.
import { List } from '@dynamicforms/vue-forms';
const people = new List(new Group({ name: new Field({ value: '' }) }));
people.push({ name: 'Ada' });
people.push({ name: 'Grace' });
transaction((tx) => {
people.push({ name: 'Katherine' });
people.remove(0);
people.length; // 2 while the transaction runs
tx.rollback();
});
people.length; // 2
people.get(0)!.value.name; // 'Ada', at its original positionNesting
A transaction() called while one is open joins it. Nothing commits until the outermost call returns, and a rollback at any level rolls back the whole transaction (there are no savepoints).
transaction(() => {
form.fields.a.value = 'outer';
transaction(() => {
form.fields.b.value = 'inner'; // joins the transaction above
});
// nothing has been announced yet
}); // one announcement, over both writesLibrary operations therefore compose: list.value = rows is a transaction of its own, and inside your transaction it becomes part of your transaction.
What a transaction may not do
A transaction cannot cross an await. A callback that returns a promise makes the call throw a TypeError as soon as the callback returns, and the transaction is rolled back:
transaction(async () => { // TypeError
await fetch('/api/thing');
});
// do the waiting outside, and open a transaction for each synchronous part
const data = await fetch('/api/thing').then((r) => r.json());
transaction(() => {
form.value = data;
});An asynchronous validator started inside a transaction is not affected by this: it opens its own transaction when it settles, and if the transaction that started it was rolled back, its result is discarded.
See also
transaction()API reference: the full contract, including what a snapshot covers- The model: where transactions sit among the other pieces
