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:
const { validate, errorsFor } = useValidation(schema, model)How a parent collects descendant registrations
- The parent creates the scope. An orchestration-only
useValidation()call establishes the shared boundary and exposes itsvalidate()action. - 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. - 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.
- Select Validate parent form with both fields empty. The parent reports two committed errors collected from its descendants.
- 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.
- Enter a name and validate again to see the shared scope succeed.
View the parent source used by this example
<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
<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><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:
<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:
<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:
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:
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:
errorsFor(['location', 'postcode'])Next task
Continue with choose between issues and errors to use structured failures for application logic and resolved strings for display.
