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 { computed, Ref, ref } from 'vue';

  import Box from '../../../src/components/Box/Box.vue';
  import Button from '../../../src/components/Button/Button.vue';
  import Checkbox from '../../../src/components/Checkbox/Checkbox.vue';
  import Input from '../../../src/components/Input/Input.vue';
  import Select from '../../../src/components/Select/Select.vue';
  import Textarea from '../../../src/components/Textarea/Textarea.vue';
  import minValue from '../../../src/composables/useValidation/ruleFactories/minValue';
  import pattern from '../../../src/composables/useValidation/ruleFactories/pattern';
  import required from '../../../src/composables/useValidation/ruleFactories/required';
  import useValidation, { ValidationRules } from '../../../src/composables/useValidation/useValidation';

  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="tw-grid tw-gap-x-4 tw-gap-y-0">
      <div class="tw-col-span-12 md:tw-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="tw-col-span-12 md:tw-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="tw-col-span-12 md:tw-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="tw-col-span-12 md:tw-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="tw-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="tw-col-span-12 tw-mb-6 tw-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="tw-col-span-12 tw-mt-4 tw-text-right">
        <Button @click="onSubmit"> Submit </Button>
      </div>
    </div>
    <div class="tw-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)
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();
}

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 '../../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>