Skip to content

Scopes and registrations ​

A scope groups registrations that must validate together. A registration is one schema and model pair.

The simplest call creates both when no scope is available:

ts
const { validate, errorsFor } = useValidation(schema, model)

How a parent collects descendant registrations ​

  1. The parent creates the scope. An orchestration-only useValidation() call establishes the shared boundary and exposes its validate() action.
  2. Each descendant registers locally. A child calls useValidation(schema, model); its schema and model join the nearest scope that already exists in its component branch.
  3. The parent validates the active collection. One validate() call runs every current registration and collects their issues. A descendant stops participating when it is disposed.

Try descendant registration ​

This form's parent calls useValidation() without a schema. Its mounted field components each call useValidation(schema, model) and automatically join that parent scope.

  1. Select Validate parent form with both fields empty. The parent reports two committed errors collected from its descendants.
  2. Clear Include the optional phone component. The phone component is disposed and its committed issue immediately leaves the parent scope; no second validation is needed.
  3. Enter a name and validate again to see the shared scope succeed.
Profile fields registered by descendants

Submit the parent form to validate every mounted field.

View the parent source used by this example
vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import { computed, nextTick, ref } from 'vue'
import NestedNameField from './NestedNameField.vue'
import NestedPhoneField from './NestedPhoneField.vue'

const includePhone = ref(true)
const hasValidated = ref(false)
const { isValidating, issues, validate } = useValidation()

const outcome = computed(() => {
  if (!hasValidated.value) {
    return 'Submit the parent form to validate every mounted field.'
  }

  const count = issues.value.length
  if (count === 0) {
    return 'No committed errors remain in the parent scope.'
  }

  return `${count} committed ${count === 1 ? 'error is' : 'errors are'} in the parent scope.`
})

async function onSubmit() {
  const result = await validate()
  hasValidated.value = true
  if (result.success) {
    return
  }

  await nextTick()
  const firstField = result.issues[0]?.path[0]
  if (firstField === 'name' || firstField === 'phone') {
    document.getElementById(`nested-${firstField}`)?.focus()
  }
}
</script>

<template>
  <div class="verific-example">
    <form novalidate @submit.prevent="onSubmit">
      <fieldset>
        <legend>Profile fields registered by descendants</legend>
        <div class="verific-example__grid">
          <NestedNameField />
          <NestedPhoneField v-if="includePhone" />
        </div>
      </fieldset>

      <label class="verific-example__toggle">
        <input v-model="includePhone" type="checkbox" data-validation-skip>
        Include the optional phone component
      </label>

      <div class="verific-example__actions">
        <button type="submit" :disabled="isValidating">
          {{ isValidating ? 'Validating…' : 'Validate parent form' }}
        </button>
      </div>

      <p class="verific-example__outcome" role="status" aria-live="polite">
        {{ outcome }}
      </p>
    </form>
  </div>
</template>
View the descendant registrations used by this example
vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import { ref } from 'vue'
import { z } from 'zod'

const name = ref('')
const { errorsFor } = useValidation(
  z.object({ name: z.string().min(1, 'Enter a name') }),
  { name },
)
</script>

<template>
  <div class="verific-example__field">
    <label for="nested-name">Name</label>
    <input
      id="nested-name"
      v-model="name"
      type="text"
      autocomplete="name"
      :aria-invalid="errorsFor('name').length > 0"
      aria-describedby="nested-name-errors"
    >
    <ul id="nested-name-errors" class="verific-example__errors" aria-live="polite" aria-atomic="true">
      <li v-for="(error, index) in errorsFor('name')" :key="`${index}:${error}`">
        {{ error }}
      </li>
    </ul>
  </div>
</template>
vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import { ref } from 'vue'
import { z } from 'zod'

const phone = ref('')
const { errorsFor } = useValidation(
  z.object({ phone: z.string().min(1, 'Enter a phone number') }),
  { phone },
)
</script>

<template>
  <div class="verific-example__field">
    <label for="nested-phone">Phone number</label>
    <input
      id="nested-phone"
      v-model="phone"
      type="tel"
      autocomplete="tel"
      :aria-invalid="errorsFor('phone').length > 0"
      aria-describedby="nested-phone-errors"
    >
    <ul id="nested-phone-errors" class="verific-example__errors" aria-live="polite" aria-atomic="true">
      <li v-for="(error, index) in errorsFor('phone')" :key="`${index}:${error}`">
        {{ error }}
      </li>
    </ul>
  </div>
</template>

Split a form across components ​

The runnable parent above establishes the shared scope before its descendants are created:

vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import ContactDetails from './ContactDetails.vue'
import PostalAddress from './PostalAddress.vue'

const { validate, issues } = useValidation()

async function submit() {
  const outcome = await validate()
  if (outcome.success) {
    // Submit application-owned state.
  }
}
</script>

<template>
  <form novalidate @submit.prevent="submit">
    <ContactDetails />
    <PostalAddress />
    <button type="submit">
      Submit
    </button>
  </form>

  <p aria-live="polite">
    {{ issues.length ? `${issues.length} validation issue(s)` : '' }}
  </p>
</template>

A descendant registers its own schema and model with the nearest scope:

vue
<script setup lang="ts">
import { useValidation } from '@verific/core'
import { reactive } from 'vue'
import { z } from 'zod'

const details = reactive({ email: '' })
const schema = z.object({ email: z.string().email() })
const { errorsFor, hasError } = useValidation(schema, details)
</script>

<template>
  <label for="contact-email">Email</label>
  <input
    id="contact-email"
    v-model="details.email"
    :aria-invalid="hasError('email')"
    :aria-describedby="hasError('email') ? 'contact-email-errors' : undefined"
  >
  <div id="contact-email-errors" aria-live="polite">
    <p v-for="(error, index) in errorsFor('email')" :key="`${index}:${error}`">
      {{ error }}
    </p>
  </div>
</template>

Component-tree rules ​

A call can join only a scope created earlier in the same component setup or provided by an ancestor. It cannot discover a scope created later, by a sibling, or in another component branch.

The nearest scope wins. Start an independent nested form explicitly:

ts
const { errorsFor, validate } = useValidation(schema, model, { scope: 'new' })

That nested scope does not inherit the outer scope's resolver or message prefix. It starts with application-level defaults.

Place a registration at a path ​

Use at when a child model represents a fragment of the scope's logical model:

ts
const { errorsFor, hasError } = useValidation(addressSchema, address, {
  at: ['shipping'],
})

hasError('postcode')
errorsFor('postcode')
// Selects the resolved path ['shipping', 'postcode'].

at changes resolved issue paths; it does not select or reshape the value passed to the schema. Nested selectors always use property-key arrays, not dotted strings:

ts
errorsFor(['location', 'postcode'])

Next task ​

Continue with choose between issues and errors to use structured failures for application logic and resolved strings for display.