Skip to content

useValidation

A composable for handling validation of form fields. The useValidation composable requires the form field values to be maintained outside of useValidation, which makes it easy to add validation to pre-existing forms.

Try "Untested Kush"
hasErrors: false
someTouched: false
dirtyFields count: 0
vue
<script lang="ts" setup>
  import Box from '@packages/vue/src/components/Box/Box.vue';
  import Button from '@packages/vue/src/components/Button/Button.vue';
  import Checkbox from '@packages/vue/src/components/Checkbox/Checkbox.vue';
  import Input from '@packages/vue/src/components/Input/Input.vue';
  import Select from '@packages/vue/src/components/Select/Select.vue';
  import Textarea from '@packages/vue/src/components/Textarea/Textarea.vue';
  import minValue from '@packages/vue/src/composables/useValidation/ruleFactories/minValue';
  import pattern from '@packages/vue/src/composables/useValidation/ruleFactories/pattern';
  import required from '@packages/vue/src/composables/useValidation/ruleFactories/required';
  import useValidation, { ValidationRules } from '@packages/vue/src/composables/useValidation/useValidation';
  import { computed, Ref, ref } from 'vue';

  interface TimezoneOption {
    id: string;
    name: string;
  }

  interface FormState {
    productName?: string;
    quantity?: number;
    sku?: string;
    timezone?: TimezoneOption;
    description?: string;
    termsAndConditionsAgreement: boolean;
  }

  function getInitialFormState(): FormState {
    return {
      productName: undefined,
      quantity: undefined,
      sku: undefined,
      timezone: undefined,
      description: undefined,
      termsAndConditionsAgreement: false,
    };
  }

  const formState: Ref<FormState> = ref(getInitialFormState());

  const blacklist = ref(['Untested Kush']);

  const mockTimezones = computed<TimezoneOption[]>(() => [
    { id: 'new_york', name: 'America/New_York' },
    { id: 'chicago', name: 'America/Chicago' },
  ]);

  const validationRules: ValidationRules<FormState> = {
    productName: [
      required(),
      {
        name: 'blacklist',
        validator(value) {
          if (!value) {
            return true;
          }

          return !blacklist.value.includes(value);
        },
        message: 'Product is blacklisted',
      },
    ],
    quantity: [minValue({ min: 0 })],
    sku: [pattern({ regex: /^[A-Z]{3}\d{3}$/, message: 'Format: ABC123' })],
    timezone: [required()],
    description: [required()],
    termsAndConditionsAgreement: [
      {
        name: 'required',
        validator(value) {
          return value === true;
        },
        message: 'Please read and agree to the terms and conditions',
      },
    ],
  };

  const validation = useValidation({ rules: validationRules, values: formState });

  async function onSubmit() {
    await validation.validate();

    if (validation.hasErrors) {
      return;
    }

    const requestBody = {
      productName: formState.value.productName?.trim(),
      quantity: String(formState.value.quantity),
      sku: formState.value.sku,
      timezone: formState.value.timezone?.id,
      description: formState.value.description?.trim(),
      termsAndConditionsAgreement: formState.value.termsAndConditionsAgreement ? 'yes' : 'no',
    };

    alert(
      `Form submitted! \n\n${Object.entries(requestBody)
        .map(([k, v]) => `${k}: ${v}`)
        .join('\n')}\n\nThe form values will now reset.`,
    );

    formState.value = getInitialFormState();
    validation.setAllUntouched();
  }
</script>

<template>
  <Box>
    <div class="grid gap-x-4 gap-y-0">
      <div class="col-span-12 md:col-span-6">
        <Input
          v-model="formState.productName"
          add-bottom-space
          :error-text="validation.getError('productName')"
          hint-text='Try "Untested Kush"'
          label="Product Name"
          @blur="validation.touch('productName')"
        />
      </div>
      <div class="col-span-12 md:col-span-6">
        <Input
          v-model="formState.quantity"
          add-bottom-space
          show-optional-in-label
          :error-text="validation.getError('quantity')"
          label="Quantity"
          type="number"
          @blur="validation.touch('quantity')"
        />
      </div>
      <div class="col-span-12 md:col-span-6">
        <Input
          v-model="formState.sku"
          add-bottom-space
          show-optional-in-label
          label="SKU"
          :error-text="validation.getError('sku')"
          @blur="validation.touch('sku')"
        />
      </div>
      <div class="col-span-12 md:col-span-6">
        <Select
          v-model="formState.timezone"
          prevent-empty
          single
          add-bottom-space
          :error-text="validation.getError('timezone')"
          label="Timezone"
          :options="mockTimezones"
          @closed="validation.touch('timezone')"
        />
      </div>
      <div class="col-span-12">
        <Textarea
          v-model="formState.description"
          add-bottom-space
          label="Description"
          :error-text="validation.getError('description')"
          @blur="validation.touch('description')"
        />
      </div>
      <div class="col-span-12 mb-6 mt-8">
        <Checkbox
          v-model:checked="formState.termsAndConditionsAgreement"
          label="I have read and agree to the terms and conditions"
          :error-text="validation.getError('termsAndConditionsAgreement')"
          @update:checked="validation.touch('termsAndConditionsAgreement')"
        />
      </div>
      <div class="col-span-12 mt-4 text-right">
        <Button @click="onSubmit"> Submit </Button>
      </div>
    </div>
    <div class="mt-6">
      <div>hasErrors: {{ validation.hasErrors }}</div>
      <div>someTouched: {{ validation.someTouched }}</div>
      <div>dirtyFields count: {{ validation.dirtyFields.length }}</div>
    </div>
  </Box>
</template>

Usage

ts
import useValidation, { minValue, required } from '@leaflink/stash/useValidation';

interface FormState {
  productName?: string;
  quantity?: number;
}

function getInitialFormState(): FormState {
  return {
    productName: undefined,
    quantity: undefined,
  };
}

const formState = ref<FormState>(getInitialFormState());

const validationRules: ValidationRules<FormState> = {
  productName: [required()],
  quantity: [required(), minValue({ min: 0 })],
};

const validation = useValidation({ rules: validationRules, values: formState });
template
<Input
  v-model="formState.productName"
  :error-text="validation.getError('productName')"
  @blur="validation.touch('productName')" <!-- Validations will not run until touch() is called -->
/>
<Input
  v-model="formState.quantity"
  :error-text="validation.getError('quantity')"
  type="number"
  @blur="validation.touch('quantity')" <!-- Validations will not run until touch() is called -->
/>

Click the "Show Code" button underneath the demo (above) to view a complete example.

Resetting a form

When a form needs to be reset so that a user can fill it out again, you must do 2 things:

  1. reset the form values
  2. reset the validation fields to be untouched; errors only appear for fields whose isTouched property is true
    • call validation.setAllUntouched() to set the isTouched property to false for all fields
    • alternatively, you can individually call validation.fields[fieldName].setTouched(false)
    • if you had set errors from an HTTP response with setApiErrors(), call validation.clearApiErrors() to clear them
ts
const validation = useValidation({ rules: validationRules, values: formState });

async function onSubmit() {
  await validation.validate();

  if (validation.hasErrors) {
    return;
  }

  await submitForm();

  alert('Form submitted!');

  formState.value = {
    productName: undefined,
    quantity: undefined,
  };

  validation.setAllUntouched();
}

Errors from HTTP (JSON:API)

When your backend returns validation errors in the JSON:API error format, you can pass them into useValidation so error messages appear under the correct fields. API errors are shown immediately (without the user having to touch the field) and appear first when a field also has client-side rule errors.

Use validation.setApiErrors(errors) with the errors array from the response. Each error should have a detail (or title) and a source.pointer that references the attribute (e.g. /data/attributes/email or /data/attributes/product_name). Pointer segments are used as-is for the field name (e.g. /data/attributes/product_nameproduct_name), so if your backend uses snake_case, use the same field names in your validation rules for those fields; you can still use camelCase for fields that don't correspond to API errors. Only errors for field names that exist in your validation rules are applied; unknown fields are ignored. Call validation.clearApiErrors() or validation.clearApiErrors(fieldName) when you want to clear API-set messages (e.g. after a successful submit or when the user changes the field).

Example response shape:

json
{
  "errors": [
    {
      "detail": "has already been taken",
      "source": { "pointer": "/data/attributes/email" }
    },
    {
      "detail": "must be at least 6 characters",
      "source": { "pointer": "/data/attributes/password" }
    }
  ]
}

Example usage:

ts
import useValidation, { required } from '@leaflink/stash/useValidation';
import type { JsonApiValidationError } from '@leaflink/stash/useValidation';

const validation = useValidation({ rules: validationRules, values: formState });

function isJsonApiValidationErrorArray(errors: unknown): errors is JsonApiValidationError[] {
  return Array.isArray(errors) && errors.every((error) => error && typeof error === 'object');
}

async function onSubmit() {
  await validation.validate();
  if (validation.hasErrors) return;

  const response = await fetch('/api/register', {
    method: 'POST',
    body: JSON.stringify({ data: { attributes: formState } }),
  });
  const body = await response.json();

  if (!response.ok && isJsonApiValidationErrorArray(body.errors) && body.errors.length) {
    validation.setApiErrors(body.errors);
    return;
  }

  validation.clearApiErrors();
  // handle success...
}

Nested attributes use dotted field names: a pointer of /data/attributes/price/min_value maps to the field name price.min_value, which must exist in your flattened validation rules.

Updating initial values

If you need to use the submitted field values as the new initial values (ex: when allowing the user to edit data multiple times), you can use validation.setInitialValues(newValues) after calling validation.setAllUntouched():

ts
const validation = useValidation({ rules: validationRules, values: formState });

async function onSubmit() {
  await validation.validate();

  if (validation.hasErrors) {
    return;
  }

  const apiResponse = await updateData();

  alert('Data updated!');

  const newInitialState = {
    productName: apiResponse.productName,
    quantity: apiResponse.quantity,
  };

  validation.setAllUntouched();
  validation.setInitialValues(newInitialState);

  formState.value = newInitialState;
}

Custom validator

ts
const validationRules: ValidationRules<FormState> = {
  productName: [
    required(),
    // custom rule
    {
      name: 'blacklist',
      validator(value) {
        if (!value) {
          return true;
        }

        return !blacklist.value.includes(value);
      },
      message: 'Product is blacklisted',
    },
  ],
}

Custom error message

Each rule's message can be either a string or a "getter" function that returns a string.

ts
import { toValue } from 'vue';

const rules = {
  productName: [
    // with custom message string
    required({ message: 'Product Name is Required' }),
  ],
  quantity: [
    minValue({ min: 0 }),
    maxValue({
      min: toValue(productsAvailable),
      message: () => `Only ${toValue(productsAvailable)} available`, // custom message function
    }),
  ],
};

WARNING

If the message includes a reactive value (such as a computed property or a ref), then message must use a getter function or else the message will only display the initial value of the reactive variable instead of its current value.

Rules in a different file

vue
<script setup lang="ts">
  import useValidation from 'composables/useValidation/useValidation';
  import getMyValidationRules, { ValidationValues } from './getMyValidationRules';

  interface FormState {
    productName?: string;
    quantity?: number;
  }

  const formState = ref<FormState>({});
  const rules = getMyValidationRules(formState);
  const validation = useValidation<ValidationValues>({ rules, values: formState });
</script>
ts
// getMyValidationRules.ts
import { MaybeRefOrGetter, readonly, toValue } from 'vue';

export interface ValidationValues {
  productName?: string;
  quantity?: number;
}

export function getMyValidationRules(formState: MaybeRefOrGetter<ValidationValues>): Readonly<ValidationRules<ValidationValues>> {
  return readonly({
    productName: [required()],
    quantity: [minValue({ min: 0 })],
  });
}

Nested fields

vue
<script lang="ts" setup>
  import { ref } from 'vue';
  import useValidation, { minValue, required } from '@packages/vue/src/composables/useValidation/useValidation';

  function getInitialFormState() {
    return {
      productName: undefined,
      price: {
        min: undefined,
        max: undefined,
      },
    };
  }

  interface FormState {
    productName?: string;
    price: {
      min?: number;
      max?: number;
    };
  }

  const formState: Ref<FormState> = ref(getInitialFormState());

  // TODO: figure out how to allow nested ValidationRules in TypeScript
  const validationRules: any = {
    productName: [required()],
    price: {
      min: [minValue({ min: 0 })],
      max: [minValue({ min: () => formState.value.price.min })],
    },
  };

  const validation = useValidation({ rules: validationRules, values: formState });
</script>

<template>
  <Input
    v-model="formState.productName"
    add-bottom-space
    :error-text="validation.getError('productName')"
    hint-text='Try "Untested Kush"'
    label="Product Name"
    @blur="validation.touch('productName')"
  />
  <Input
    v-model="formState.price.min"
    add-bottom-space
    :error-text="validation.getError('price.min')"
    label="Price Min"
    type="number"
    @blur="validation.touch('price.min')"
  />
  <Input
    v-model="formState.price.max"
    add-bottom-space
    :error-text="validation.getError('price.max')"
    label="Price Max"
    type="number"
    @blur="validation.touch('price.max')"
  />
</template>