21

useNotForm

Create a type-safe form instance with schema validation, reactive state, field tracking, and submission handling.

useNotForm is the core composable for creating and managing a NotForm instance.

It connects a Standard Schema-compatible validator to a deeply reactive form state and provides everything needed to manage values, validation, field interaction, errors, submission, and reset behavior.

The returned form instance can be used directly or provided to <NotForm> for use by descendant field components.

Signature

function useNotForm<TSchema extends ObjectSchema>(
  config: UseNotFormConfig<TSchema>,
): NotFormInstance<TSchema>

The generic type is inferred from the supplied schema.

The schema drives the types of:

  • form.values
  • form.setValue()
  • field paths
  • validation results
  • onSubmit()
  • form.validateField()
  • form.reset()

Configuration

useNotForm accepts a UseNotFormConfig object.

schema

schema: MaybeRefOrGetter<TSchema>

The validation schema used to parse and validate form data.

The schema can be provided directly or through a Vue ref or getter.

import { z } from 'zod'

const schema = z.object({
  email: z.email(),
  password: z.string().min(8),
})

const form = useNotForm({
  schema,

  onSubmit(values) {
    console.log(values)
  },
})
The schema must be a Standard Schema-compatible object whose input is an object.

The schema also determines the output passed to onSubmit.

For example, a schema can transform its input:

const schema = z.object({
  age: z.coerce.number(),
})

const form = useNotForm({
  schema,

  onSubmit(values) {
    // `values.age` is the schema's output type
    console.log(values.age)
  },
})

initialValues

initialValues?: DeepPartial<StandardSchemaV1.InferInput<TSchema>>

The initial values for the form.

The supplied values become the form's initial baseline and are used for:

  • populating form.values
  • dirty-state comparisons
  • form.reset()

Because the type is deeply partial, nested values do not necessarily need to be supplied in full.

const form = useNotForm({
  schema,

  initialValues: {
    email: 'user@example.com',
    profile: {
      name: 'Jane',
    },
  },

  onSubmit(values) {
    console.log(values)
  },
})

When initialValues is omitted, the form starts from an empty object.

The stored baseline is exposed through form.initialValues.

initialErrors

initialErrors?: StandardSchemaV1.Issue[]

Initial validation issues for the form.

This is useful when a form needs to start with errors returned from another source, such as previously persisted validation state.

const form = useNotForm({
  schema,

  initialErrors: [
    {
      message: 'This email is already registered.',
      path: ['email'],
    },
  ],

  onSubmit(values) {
    console.log(values)
  },
})

The errors become the form's initial error baseline and are exposed through form.initialErrors.

validateOn

validateOn?: Partial<Record<ValidationTrigger, boolean>>

Controls which interaction events trigger validation.

Available triggers are:

type ValidationTrigger
  = | 'onBlur'
    | 'onChange'
    | 'onFocus'
    | 'onInput'
    | 'onMount'

The default configuration is:

{
  onBlur: true,
  onChange: true,
  onFocus: false,
  onInput: true,
  onMount: false,
}

Only the values you provide are overridden.

For example:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onInput: false,
  },

  onSubmit(values) {
    console.log(values)
  },
})

The resolved configuration is available through form.validateOn.

Individual field components can override supported triggers on a per-field basis.

validationMode

validationMode?: 'eager' | 'lazy'

Controls how validation is applied after field interaction.

The default is:

validationMode: 'eager'

eager

After a field has an error, subsequent changes can continue to validate it immediately.

lazy

Validation is deferred until blur or submission.

const form = useNotForm({
  schema,

  validationMode: 'lazy',

  onSubmit(values) {
    console.log(values)
  },
})

The resolved mode is exposed through form.validationMode.

onSubmit

onSubmit?: (
  values: StandardSchemaV1.InferOutput<TSchema>
) => Promise<void> | void

Called after the complete form has been validated successfully.

The callback receives the schema's output type, not necessarily its raw input type.

const form = useNotForm({
  schema,

  async onSubmit(values) {
    await saveUser(values)
  },
})

onSubmit is optional. A form can still be created and validated without providing a submit callback.

Returned Form Instance

useNotForm returns a NotFormInstance.

const form = useNotForm({
  onSubmit,
  schema,
})

The instance contains the form's baseline state, reactive values, validation configuration, field tracking, errors, validation methods, submission state, and reset API.

Baseline State

initialValues

readonly initialValues: DeepPartial<StandardSchemaV1.InferInput<TSchema>>

The values currently used as the form's baseline.

These are the values used when determining whether fields are dirty.

form.initialValues

Calling reset() without arguments restores these values.

When new values are provided to reset(), they replace this baseline.

initialErrors

readonly initialErrors: StandardSchemaV1.Issue[]

The errors currently used as the form's error baseline.

form.initialErrors

Calling reset() restores these errors.

Providing new errors to reset() replaces this baseline.

Validation Configuration

validateOn

readonly validateOn: {
  onBlur: boolean
  onChange: boolean
  onFocus: boolean
  onInput: boolean
  onMount: boolean
}

The resolved validation triggers for the form.

Unlike the optional validateOn passed to useNotForm, this property always contains all trigger keys.

console.log(form.validateOn.onBlur)
console.log(form.validateOn.onInput)

validationMode

readonly validationMode: 'eager' | 'lazy'

The resolved validation mode for the form:

console.log(form.validationMode)

Values

values

values: StandardSchemaV1.InferInput<TSchema>

values is the deeply reactive object containing the current form values.

It can be accessed directly:

console.log(form.values.email)

Because it is reactive, it can also be used directly with v-model:

<input v-model="form.values.email" />

Nested values work the same way:

<input v-model="form.values.address.city" />

setValue(path, value)

setValue(
  path: Paths<StandardSchemaV1.InferInput<TSchema>>,
  value: Get<StandardSchemaV1.InferInput<TSchema>, path>,
): void

Sets a value using a dot-separated field path.

This is useful for custom inputs or deeply nested values where v-model is not appropriate.

form.setValue('email', 'user@example.com')

form.setValue('address.city', 'Lagos')

The field path and value are typed from the schema.

Calling setValue() also updates dirty tracking by comparing the new value with the current initialValues.
If the value matches its initial value again, that field is removed from dirtyFields.
setValue() does not itself trigger validation. Validation is driven by the configured field interaction handlers.

Touched State

touchedFields

touchedFields: Set<Paths<StandardSchemaV1.InferInput<TSchema>>>

The set of field paths that have been marked as touched.

console.log(form.touchedFields)

A field is normally marked as touched by its NotField blur handler.

During submission, all existing field paths are marked as touched so validation feedback can surface across the form.

isTouched

isTouched: ComputedRef<boolean>

Indicates whether any field in the form has been touched.

<p v-if="form.isTouched">
  The form has been touched.
</p>

In JavaScript or TypeScript, use .value:

if (form.isTouched.value) {
  console.log('Form has been touched')
}

touchField(path)

touchField(
  path: Paths<StandardSchemaV1.InferInput<TSchema>>,
): void

Marks a specific field as touched.

form.touchField('email')

This is normally handled automatically by field components but is useful for custom form controls.

Dirty State

dirtyFields

dirtyFields: Set<Paths<StandardSchemaV1.InferInput<TSchema>>>

The set of field paths whose values currently differ from initialValues.

console.log(form.dirtyFields)

isDirty

isDirty: ComputedRef<boolean>

Indicates whether any field is dirty.

<p v-if="form.isDirty">
  You have unsaved changes.
</p>

In JavaScript:

if (form.isDirty.value) {
  console.log('Form has unsaved changes')
}

dirtyField(path)

dirtyField(
  path: Paths<StandardSchemaV1.InferInput<TSchema>>,
): void

Manually marks a field as dirty.

form.dirtyField('email')

Normal field interactions update dirty tracking automatically.

Errors

errors

errors: StandardSchemaV1.Issue[]

The raw validation issues produced by the most recent validation run.

console.log(form.errors)

Each issue contains the path and message supplied by the validation schema.

for (const error of form.errors) {
  console.log(error.path)
  console.log(error.message)
}

errorsMap

errorsMap: ComputedRef<
  Partial<Record<Paths<StandardSchemaV1.InferInput<TSchema>>, string>>
>

A convenient flat map of field paths to their first active error message.

form.errorsMap.email
form.errorsMap['address.city']

For example:

<p v-if="form.errorsMap.email">
  {{ form.errorsMap.email }}
</p>

errorsMap is useful when you only need the first message for a field.

Use errors or getFieldErrors() when you need the complete validation issues.

setError(error)

setError(error: StandardSchemaV1.Issue): void

Adds or replaces the error for a specific path.

If an issue already exists for the same normalized path, it is replaced.

form.setError({
  message: 'This email is already registered.',
  path: ['email'],
})

This is useful for server-side validation errors.

setErrors(errors)

setErrors(errors: StandardSchemaV1.Issue[]): void

Replaces all active errors.

form.setErrors([
  {
    message: 'Email is already registered.',
    path: ['email'],
  },
  {
    message: 'Username is already taken.',
    path: ['username'],
  },
])

clearErrors()

clearErrors(): void

Removes every active validation error.

form.clearErrors()

getFieldErrors(path)

getFieldErrors(
  path: Paths<StandardSchemaV1.InferInput<TSchema>>,
): StandardSchemaV1.Issue[]

Returns all active validation issues associated with a particular field path.

const errors = form.getFieldErrors('email')

Unlike errorsMap, this does not discard additional issues for the same field.

for (const error of form.getFieldErrors('email')) {
  console.log(error.message)
}

Validation

isValidating

isValidating: Ref<boolean>

Indicates whether one or more validation operations are currently running.

<p v-if="form.isValidating">
  Validating...
</p>

The state remains active while concurrent validation operations are still pending.

validate()

validate(): Promise<
  StandardSchemaV1.Result<
    StandardSchemaV1.InferOutput<TSchema>
  >
>

Validates the entire form against the current schema.

const result = await form.validate()

When validation succeeds:

const result = await form.validate()

if ('value' in result) {
  console.log(result.value)
}

When validation fails, the returned result contains issues and those issues replace the form's current errors.

A successful validation clears the current errors.

validateField(path)

validateField(
  path: Paths<StandardSchemaV1.InferInput<TSchema>>,
): Promise<StandardSchemaV1.Result<...>>

Validates a single field against the complete schema.

const result = await form.validateField('email')
validateField() only replaces errors associated with that field.
Errors belonging to other fields remain untouched.
When the validation result contains issues, only issues matching the requested path are added to the form's error state.
For successful validation, the field's current value is returned.
This is useful when building custom inputs that need to validate themselves without validating the entire form.

Form Validity

isValid

isValid: ComputedRef<boolean>

Indicates whether the form currently has no active validation errors.

<button
  type="submit"
  :disabled="!form.isValid"
>
  Submit
</button>

In JavaScript:

if (form.isValid.value) {
  console.log('The form is valid')
}

isValid is based on the current errors collection. It does not independently execute validation.

Submission

isSubmitting

isSubmitting: Ref<boolean>

Indicates whether the form's onSubmit handler is currently running.

<button
  type="submit"
  :disabled="form.isSubmitting"
>
  {{ form.isSubmitting ? 'Submitting...' : 'Submit' }}
</button>

submit(event)

submit(event: SubmitEvent): Promise<void>

Submits the form.

The method:

  1. Prevents the browser's native form submission.
  2. Marks all existing field paths as touched.
  3. Marks all existing field paths as dirty.
  4. Validates the complete form.
  5. Aborts when validation fails.
  6. Calls onSubmit with the schema's validated output when validation succeeds.

Bind it directly to a native <form>:

<form @submit="form.submit">
  ...
</form>

Example:

<form @submit="form.submit">
  <input v-model="form.values.email" />

  <button
    type="submit"
    :disabled="form.isSubmitting"
  >
    {{ form.isSubmitting ? 'Submitting...' : 'Submit' }}
  </button>
</form>

When validation fails, onSubmit is not called.

When no onSubmit callback was provided, the form still performs validation but has no submission callback to execute.

Reset

reset()

reset(
  values?: DeepPartial<StandardSchemaV1.InferInput<TSchema>>,
  errors?: StandardSchemaV1.Issue[],
): void

Resets the form and clears all touched and dirty tracking.

Calling reset() without arguments restores the current initialValues and initialErrors:

form.reset()

Reset With New Values

Pass new values to replace the current baseline:

form.reset({
  email: 'jane@example.com',
  name: 'Jane',
})

Those values become the new initialValues.

A later call to:

form.reset()

will restore these values.

Reset With New Errors

You can provide both values and errors:

form.reset(
  {
    email: 'jane@example.com',
    name: 'Jane',
  },
  [
    {
      message: 'Email requires verification.',
      path: ['email'],
    },
  ],
)

The supplied errors become the new initialErrors.

Calling reset() afterward restores both the new values and those errors.

Reset Only Values

Because the two parameters are positional, passing new values while keeping existing baseline errors is possible:

form.reset({
  email: 'new@example.com',
})

The existing initialErrors remain unchanged.

Reset Only Errors

To replace only the baseline errors while leaving the current baseline values unchanged:

form.reset(undefined, [
  {
    message: 'Email requires verification.',
    path: ['email'],
  },
])