Skip to content

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.

Cart
Mug
12.00
Teapot
35.00
Coasters
8.00

Total 91.00

State

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 ValueChangedAction on the cart. Each write of a quantity announces once. Fill address writes the pickup flag, the street and the city in one transaction(), and the counter goes up by one.
  • Rollback. Edit, then cancel sets every quantity to 10 and a coupon, reads the computed total inside the transaction, and calls tx.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 watchEffect sets the delivery address to 'disabled-null' while the cart is collected in the shop: the output shows delivery: 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 computed over the view and recomputes when a quantity changes.
Source code
vue
<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.

typescript
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 address

A 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:

typescript
announcements.length = 0;
transaction(() => {
  form.fields.city.value = 'Koper';
  form.fields.city.value = 'Maribor';   // back to what it held
});
announcements.length;  // 0

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

typescript
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 announced

Undoing an edit ​

tx.rollback() undoes everything the transaction did and announces nothing. Use it for a cancel button on an editing dialog.

typescript
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 cancelled

The 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:

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

typescript
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 position

Nesting ​

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

typescript
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 writes

Library 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:

typescript
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 ​

Released under the MIT License.