.md

useRegister

The bridge between v-register on a wrapper component and v-register on its inner native input.

Category
Composable
Signature
useRegister<V>(options?): UseRegisterReturn<V> | undefined
Returns
hybrid Ref + RegisterValue Proxy
Auto-installed
attaform plugin

Type into either of the two fields rendered by <FieldRow> and watch the JSON readout update. The parent applies v-register="form.register('email')" to the <FieldRow> root; inside FieldRow, useRegister() captures that binding and v-register="rv" reattaches it to the inner <input>. The sections below trace every step.

useRegister Demo Open in playground
App.vue · parent

Each FieldRow only takes a label prop. The schema path arrives ambiently: the parent below applies v-register to the row, and useRegister() inside the row picks it up.

{
  "email": "",
  "handle": ""
}

When to reach for it

useRegister exists for one specific shape: a Vue component that wraps a single form field, whose root element is not the input itself:

<!-- A labeled-row wrapper. The root is <label>, not the input. -->
<FieldRow v-register="form.register('email')" label="Email" />

Inside <FieldRow>, the wrapper's root receives the directive, but you need the binding on the inner <input> to make the form work. useRegister captures the parent's v-register binding so you can re-apply it inside.

When the wrapper's root is the input itself, Vue's attribute fallthrough already handles the binding, so useRegister is unnecessary.

Capture, re-apply

<script setup lang="ts">
  import { useRegister } from 'attaform'

  const rv = useRegister()
</script>

<template>
  <label class="field-row">
    <span>{{ label }}</span>
    <input v-register="rv" />
  </label>
</template>

That's the whole pattern: call once, hand the return value to v-register inside. useRegister reads the parent's binding, surfaces it as a hybrid Ref + RegisterValue Proxy, and the directive picks it up exactly as if the parent had applied it directly to the inner input.

Two surfaces on the return value

useRegister() returns a hybrid Proxy:

  • Ref-shaped: v-register="rv" works because the Proxy answers __v_isRef / .value like a Ref<RegisterValue | undefined>. Vue's template auto-unwrap surfaces the underlying RegisterValue to the directive.
  • Pierced reads: rv.path, rv.segments, rv.formKey, rv.formInstanceId all work directly in script setup without .value. Reads inside reactive scopes (computed, watchEffect) track changes.
const rv = useRegister()

// In script setup: direct pierce
const ariaLabel = computed(() => `Field for ${rv?.path}`)

// In template: Vue auto-unwraps
// v-register="rv"

Unbound state

When the parent didn't apply v-register, every pierced read returns undefined and the composable's return type surfaces this honestly as UseRegisterReturn<V> | undefined:

const rv = useRegister()
// rv?.formKey: optional-chain to defend

In dev mode, a single console.warn fires per instance at mount time when the parent never bound. The directive accepts undefined peacefully (its binding type is RegisterValue<V> | undefined), so v-register="rv" works whether or not a parent bound. No runtime crash, just the warn.

A wrapper meant to work both ways says so, and the warn stands down:

const rv = useRegister({ optional: true })

Leave it off for a wrapper that always expects a binding. There the warn earns its keep: a parent that forgot v-register renders a field that looks fine and stores nothing, and only the author knows which of the two a given component is.

rv?.isBound is the test for "did a parent bind me", and it is the one to reach for. Script code holds the hybrid Proxy, which is an object and so always truthy; a template holds the unwrapped ref, which is undefined when unbound. Those two disagree, and isBound reads the same on both:

const rv = useRegister()
if (rv?.isBound) {
  // a parent bound this wrapper to a field
}

It is reactive in both directions, because the answer can change after mount. A parent is free to bind on a later render, which is also why useRegister cannot simply return undefined when nothing has bound yet: at setup time it does not know, and giving up the Proxy would sever the binding that arrives later.

<AddressField v-register="ready ? form.register('street') : undefined" />

One component, bound and unbound

A wrapper that calls useRegister({ optional: true }) still works when nobody binds it, and it does not need a second copy of its markup to manage that. Write the plain binding you would write without Attaform, and v-register takes over whenever a field resolves:

<select v-register="rv" :value="modelValue" @change="onChange">
  <option value="">None</option>
  <option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>

Bound, the field drives the control and the :value is inert. Unbound, the :value is the whole binding and behaves exactly as it would on a bare <select>. The same holds for :value on a text input or textarea and for :checked on a checkbox or radio.

An <option>'s own :selected works the same way, so a hand-marked option list keeps its marks when nobody binds the <select>.

One edge to know: v-model is not a substitute for :value here. It installs Vue's own model directive beside v-register, so two writers drive one element, and that still warns.

Multi-field wrappers

useRegister is the right call for single-field wrappers. For compound components binding multiple paths (a date-range picker exposing start + end fields, an address subform exposing street + city + zip), save injectForm<Form>() into a form handle and call form?.register(path) for each one. That sidesteps the single-binding assumption useRegister makes.

Where to next