---
title: '4. Mutations & forms'
description: One typed write, one endpoint, two response modes — a real form without JavaScript, the fragment wire with it.
order: 4
---

# Mutations & forms

Your shop shows live data; now you'll sell something. In this chapter you add `cart/add`: a typed
write behind a real HTML form. The same endpoint handles no-JS POST-redirect-GET and the enhanced
fragment response. Step state: `site/tutorial/steps/04-mutations/`.

## Declare the input

```ts
export const addToCartInput = s.object({
  productId: s.string(),
  quantity: s.number().int().min(1).default(1),
});
```

This is the form contract. `quantity` arrives as a string, and the schema says how it becomes a
number.

## Add the CSRF token source

Before the form can submit, the request shell needs a token source. Mutations are browser-reachable
POSTs, so Kovo stamps a `kovo-csrf` hidden field into the form and verifies it before input parsing:

```ts
export const shopCsrf = {
  secret: tutorialDeploymentSecret(
    'KOVO_TUTORIAL_SHOP_CSRF_SECRET',
    EXAMPLE_ONLY_TUTORIAL_SHOP_CSRF_SECRET,
  ),
  sessionId(request: ShopRequest) {
    return request.session?.id;
  },
};
```

```ts
it('fails closed on a POST without the session-bound CSRF token', async () => {
  const request = shopRequest();
  const response = await submitAddToCart(
    { productId: 'p1', quantity: '1' }, // no kovo-csrf field
    request,
    {
      'Kovo-Form-Target': 'add-to-cart:p1',
      'Kovo-Fragment': 'true',
      'Kovo-Targets': 'add-to-cart:p1',
    },
  );

  expect(response.status).toBe(422);
  expect(response.body).toContain('data-error-code="CSRF"');
  expect(request.db.cartItems).toEqual([]);
});
```

## Declare the mutation

The mutation now has its token source, input schema, failure vocabulary, transaction wrapper, and
handler in one place. This step also lists the query definitions the request shell can rerun after
commit; the next chapters show how the invalidation graph derives that set from domains and writes
instead of making you maintain it by hand.

```ts
export const addToCart = mutation({
  access: publicAccess('tutorial anonymous single-cart write protected by CSRF'),
  csrf: shopCsrf,
  input: addToCartInput,
  errors: {
    OUT_OF_STOCK: s.object({ availableQuantity: s.number().int().min(0) }),
  },
  registry: {
    queries: [cartQuery, productsQuery],
    touches: [cart, product],
  },
  transaction(request: ShopRequest, run) {
    return request.db.transaction((db) => run({ ...request, db }));
  },
  handler(input, request: ShopRequest, context) {
    const found = request.db.products.get(input.productId);
    if (!found || found.stock < input.quantity) {
      return context.fail('OUT_OF_STOCK', { availableQuantity: found?.stock ?? 0 });
    }

    request.db.write('cart_items', {
      productId: input.productId,
      qty: input.quantity,
      unitPrice: found.unitPrice,
    });
    request.db.write('products', {
      ...found,
      stock: found.stock - input.quantity,
    });
    return { productId: input.productId, quantity: input.quantity };
  },
});
```

`errors` gives the form a typed `OUT_OF_STOCK` state. `transaction` gives `fail()` a rollback
boundary. The step's single anonymous cart is public by design, so `publicAccess(...)` records the
access decision. It does not disable CSRF: the browser POST still has to carry the framework-minted,
request-bound token before parsing or handler work.

The step's tiny database makes the commit/rollback boundary concrete:

```ts
async transaction(run) {
  const draft = cloneShopDb(db);
  const result = await run(draft);

  // Commit: the draft becomes the database. A thrown error (fail()
  // rolls back this way) discards the draft instead.
  db.cartItems = draft.cartItems;
  db.products = draft.products;

  return result;
},
```

## Render the no-JS form

The product list component renders the add-to-cart form — a real form, posting to the
mutation's named endpoint. The no-JS form is the contract the enhanced path upgrades, not a
fallback bolted on afterward:

```tsx
// The no-JS add-to-cart form posts to the mutation endpoint; `enhance`
// upgrades it to the fragment wire. Authored `key` gives repeated forms
// stable identity, and the compiler derives the submitted-form target. Kovo
// emits the mutation-bound CSRF and canonical Kovo-Idem fields together.
export function renderAddToCartForm(
  item: Pick<ShopProduct, 'id' | 'stock'>,
  failure?: AddToCartFailure,
  _request?: ShopRequest,
) {
  return (
    <form enhance mutation={addToCart} key={item.id}>
      <input type="hidden" name="productId" value={item.id} />
      <label>
        Qty
        <input name="quantity" type="number" min="1" max={item.stock} value="1" />
      </label>
      <button type="submit">Add</button>
      {failure ? (
        renderAddToCartError(failure)
      ) : (
        <FormError
          code="OUT_OF_STOCK"
          role="alert"
          message={(formFailure: { payload: { availableQuantity?: number } }) =>
            `Only ${formFailure.payload.availableQuantity ?? 0} available.`
          }
        />
      )}
    </form>
  );
}
```

`enhance` is the entire opt-in: with JavaScript, the loader intercepts the submit and speaks the
fragment wire; without it, the browser posts natively. Repeated forms use ordinary keyed identity,
and the compiler derives the submitted-form target so failures can re-render just that instance.
Either way the wire stays legible — a named POST to `/_m/cart/add` with schema-shaped fields.

```ts
it('renders the complete add-to-cart form bundle as the page output', async () => {
  const request = shopRequest();
  const html = await renderShopPageForTest(request);
  const action = `/_m/${addToCart.key}`;

  expect(html).toContain(
    `enhance method="post" action="${action}" data-mutation="${addToCart.key}"`,
  );
  expect(html).toContain('name="kovo-csrf"');
  expect(html).toContain('name="Kovo-Idem"');
  expect(html).toContain('name="productId" value="p1"');
  expect(html).toContain('name="quantity" type="number" min="1" max="5" value="1"');
  expect(html).toContain('name="kovo-form-key" value="p1"');
});
```

## Mode one: no JavaScript

```ts
it('handles no-JS success as POST-redirect-GET', async () => {
  const request = shopRequest();

  // FormData arrives as strings; the schema declared the coercion once.
  const response = await submitAddToCartNoJs(
    formInput(request, { productId: 'p1', quantity: '2' }),
    request,
  );

  expect(response).toEqual({
    body: '',
    headers: {
      'Cache-Control': 'no-store',
      Location: '/',
    },
    status: 303,
  });
  expect(request.db.cartItems).toEqual([{ productId: 'p1', qty: 2, unitPrice: 1499 }]);
  await expect(renderShopPageForTest(request)).resolves.toContain('(3 in stock)');
});
```

Success is POST-redirect-GET — status 303, fresh page, no resubmit-on-refresh. Failure
re-renders the full page with the typed error in place and HTTP 422, the form still filled in:

```ts
it('handles no-JS failure as a full 422 page with the form re-rendered', async () => {
  const request = shopRequest();
  const response = await submitAddToCartNoJs(
    formInput(request, { productId: 'p2', quantity: '3' }),
    request,
  );

  expect(response.status).toBe(422);
  expect(response.headers['Content-Type']).toBe('text/html; charset=utf-8');
  expect(response.body).toContain(`enhance method="post" action="/_m/${addToCart.key}"`);
  expect(response.body).toContain('data-error-code="OUT_OF_STOCK"');
  expect(response.body).toContain('Only 2 available.');
  expect(request.db.cartItems).toEqual([]); // fail() rolled the transaction back
});
```

Users without the enhancements get a working website. That degradation is structural, not
aspirational.

## Mode two: the fragment wire

With JavaScript, the same endpoint sees an `Kovo-Fragment` header and answers with readable chunks:
query values or fragments for the live targets declared by `kovo-deps` stamps. The server holds no
record of what's on screen — it answers a stateless question:

```ts
it('answers the enhanced path with readable fragments from the same endpoint', async () => {
  const request = shopRequest();
  const response = await submitAddToCart(
    formInput(request, { productId: 'p1', quantity: '2' }),
    request,
    {
      'Kovo-Fragment': 'true',
      'Kovo-Live-Targets':
        'cart-badge#components/cart-badge/cart-badge:{}; product-list#components/product-list/product-list:{}',
      'Kovo-Targets': `cart-badge=${cartQuery.key}; product-list=${productsQuery.key}`,
    },
  );

  expect(response.status).toBe(200);
  expect(response.headers['Content-Type']).toBe('text/vnd.kovo.fragment+html; charset=utf-8');
  expect(response.body).toContain('<kovo-fragment target="cart-badge">');
  expect(response.body).toContain('<span data-bind="cart.count">2</span>');
  expect(response.body).toContain('<kovo-fragment target="product-list">');
  expect(response.body).toContain('(3 in stock)');
});
```

Fragments come from the same component renders as full pages, so partials can't drift from
pages. They are DOM-morphed in — patched in place, not replaced — so focus, scroll, and island
state survive; a fragment update is a tiny navigation. Failures ride the same wire, scoped to the
form that caused them:

```ts
it('answers enhanced failures as a re-rendered form fragment', async () => {
  const request = shopRequest();
  const response = await submitAddToCart(
    formInput(request, { productId: 'p2', quantity: '3' }),
    request,
    {
      'Kovo-Fragment': 'true',
      'Kovo-Form-Target': 'add-to-cart:p2',
      'Kovo-Targets': 'add-to-cart:p2',
    },
  );

  expect(response.status).toBe(422);
  expect(response.body).toContain('<kovo-fragment target="add-to-cart:p2">');
  expect(response.body).toContain('name="kovo-form-key" value="p2"');
  expect(response.body).toContain('data-error-code="OUT_OF_STOCK"');
  expect(request.db.cartItems).toEqual([]);
});
```

The [mutations guide](/guides/mutations/) covers guards, file uploads, and response headers.

You now have a real write, working with and without JavaScript. The next chapter makes the refresh
path feel instant by adding optimistic query transforms.

<details>
<summary>Spec & diagnostics</summary>

`input` schema as single source of truth, validators required: SPEC §6.3, §6.6. `errors` as a
typed discriminated union: SPEC §9.2. Transaction lifecycle and rollback: SPEC §10.3. CSRF
default-on, fail-closed: SPEC §6.6. No-JS degradation as a structural contract: SPEC §8. Legible
named POST: Constitution #4. Stateless fragment responses keyed off live `kovo-deps`: SPEC §9.1.
Submitted-form target inference and typed failure state: SPEC §6.3, §9.2.
Explicit default-deny access decisions: SPEC §10.2, **KV436**.

</details>
