.md

Performance

Notes on the hot paths and what to look at if a form starts feeling slow. Measured numbers, sizing guidance, and the array-helper gotcha worth knowing.

Category
Reference
Default
no tuning under 500 leaves
Sweet spot
500 – 5,000 leaves
Frame budget
16.7 ms @ 60 fps

This page is reference material; no demo. The benches live in the repo and CI runs them on every PR, so they stay honest about the code they ship beside.

Measured numbers

These come from pnpm bench, the microbenchmark suite under bench/. They run under Vitest on Node, single-threaded, against a mounted form. For browser numbers, and for how Attaform lands against other Vue form libraries on the same scenarios, see Benchmarks. Your machine will sit elsewhere on the number line; the shapes below are what carry over.

OperationCost
Scalar write, flat form of 5 / 50 / 500 fields3.6 / 3.8 / 3.9 µs
Scalar write at path depth 3 / 8 / 165.8 / 9.4 / 16 µs
Row-field write, array of 10 / 100 / 1,000 rows~5.5 µs
Validation on that write (validateOn: 'change')+3.5 µs
Same write with debounceMs: 200 (steady-state typing)+1.2 µs
Submit lifecycle (validate → onSubmit → setErrors)9.8 µs
Discriminated union, write inside the active variant8.0 µs
Discriminated union, cross-variant flip29 µs
Cold form construction, 5 / 50 / 500 fields0.08 / 0.23 / 2.5 ms
reset(), 100-leaf object form255 µs
Field-array append, 100 / 1,000 items0.47 / 0.61 ms
Field-array swap, 500 items0.42 ms
Path canonicalization, cache hit36 ns

A 60 fps frame is 16.7 ms. A keystroke lands three orders of magnitude inside it, and Vue's render gets the rest of the frame.

The first row is the one to read twice: a write costs the same on a 500-field form as on a 5-field one. Attaform writes through the path you name, so the cost tracks how deep that path goes, not how much else the form holds. Row 3 says the same thing about array length. Breadth shows up in cold construction and in whole-form validation, not on the keystroke.

Hot-path characteristics

  • Writes: setValue walks only the named path's subtree, which is why the flat-form row holds across a 100x range in field count. bench/matrix.bench.ts sweeps field count, depth, and array width against a mounted form.
  • The diff writer: bench/keystroke.bench.ts holds the patch-emitting writer against the whole-form flatten it replaced, and is the one bench CI gates on ratio rather than absolute cost.
  • form.meta.dirty: iterates the tracked leaves with no per-leaf parse cost. Each entry stores its own path segments, so the walk never re-parses a path key.
  • Path resolution: dotted-string paths are cached (128 entries, FIFO eviction), so repeat canonicalization reduces to a map lookup.

Sub-500-leaf forms don't surface in profiling.

Sizing guidance

ScaleGuidance
≤ 500 leavesDefault. No tuning needed.
500 – 5,000 leavesStill fine. Typing does not get slower; what grows is cold construction and any whole-form aggregate you render in a hot scope.
5,000+ leavesConsider splitting into sub-forms with distinct keys, composed via injectForm or useWizard.

Array helpers are O(N)

append / prepend / insert / remove / swap / move all copy the target array before mutating. That's cheap in the common case (dozens of items), fine at hundreds, but quadratic if you loop append to seed a large list. For a large seed, assign the whole array in one shot:

form.setValue('items', preBuiltArray) // O(N): one allocation

For incremental population (the user appends one item at a time), per-append cost is the only thing that matters and the amortized total is linear over the user's interactions.

Keying v-for rows

Iterate a reorderable list with form.list and key on row.key. That token is minted once per element and travels with it through insert, remove, move, and swap, so there is nothing to carry on the data and no id to generate yourself:

<!-- Stable: the key follows the element, whatever the list does around it -->
<div v-for="(row, i) in form.list('items')" :key="row.key">
  <input v-register="form.register(`items.${i}.title`)" />
</div>

<!-- Avoid for reorderable lists: the index names a slot, not an element -->
<div v-for="(_, i) in form.values.items" :key="i">…</div>

Keying by index ties each row to a position, so a reorder reshuffles which DOM node and component instance render which element, and a half-typed input can land on the wrong row. The index pattern is fine for append-only or short-lived lists.

Discriminated unions vs. plain unions

A discriminated union reads the discriminator and validates exactly one branch. A plain z.union has no such key, so it works through its options to find one that fits, and a failing value is the case where it works through all of them. The gap widens with the branch count. Reach for z.discriminatedUnion whenever the variants share a literal key; it is faster, and it also gives Attaform the reshape and variant-memory behaviour a plain union cannot support.

form.meta.dirty in hot templates

form.meta.dirty is a whole-form aggregate; it invalidates whenever any tracked leaf's updatedAt ticks. If you render it in a hot path (a header that re-renders on every keystroke), derive a more specific predicate instead:

// Faster than gating on the whole-form form.meta.dirty:
const isEmailDirty = computed(() => form.fields.email.dirty)

The pattern: read at the smallest granularity that gives you the answer you need.

Reset cost

reset() is sub-millisecond on a 100-leaf form (~255 µs in the suite; see the table above). It is the one whole-form rebuild in the write API, so unlike a keystroke it does scale with leaf count. resetField(path) scales with the subtree instead; prefer it for localized reversions.

Benching your own form

Clone the repo and drop a bench in bench/:

import { bench, describe } from 'vitest'
import { z } from 'zod'
// import your form setup

describe('my form: typical interaction', () => {
  bench('the operation I care about', () => {
    // ...
  })
})

Run with pnpm bench. The regression gate only fires on benches that follow the old: / new: pairing convention; informational benches run without gating.

Peer-dep coverage

Per-PR CI runs the suite on whichever Node release is current LTS at run time, pinned as lts/* rather than a version number so it rolls forward on its own; the engines.node floor is Node 22, with no upper bound. A weekly workflow sweeps Vue 3.5 through 3.6, Vite 5 / 6, Nuxt 3.16 through Nuxt 4. Jobs fail independently; versions not yet released surface as failed cells without blocking the main CI.

Where to next

  • Benchmarks: the same scenarios run across the Vue form-library field, with bundle size, supply-chain scores, and per-scenario runtime tables.
  • Field-array mutations: the O(N) characteristics in full, including amortized analysis.
  • How values are stored: the slim write shape that keeps reads fast.
  • SSR hydration: Nuxt: hydration costs depend on form size; pair this page with the SSR pages when sizing.