Zod Form Component
15 Sep 2024

Came across the Zod library some time ago but did not had the opportunity to play around with it. However during a recent stint at work, I had the chance to use it and decided to build a generic component to handle some of the boiler plate when it comes to creating form with validation. If you worked substantially on forms before, you would know how many different ways of implementing a form with validation among different developers. My goal here is to increase readability and set a convention for my team when it comes to designing forms with validation.

The elegant thing about the Zod library is that it helps the developer to see the form that we are developing: its schema and its validation rules against it at the same time. On top of that, we can infer a Typescript type out of it by using the helpful z.infer<> type.

Using the component

This is how I would envision a developer would use the component with Zod schema and validation.

<script setup lang="ts">
import { ref } from "vue";
import { z } from "zod";
import ZodForm from "./components/zod-form/ZodForm.vue";
import ZodFormField from "./components/zod-form/ZodFormField.vue";

const formSchema = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "Passwords do not match",
    path: ["confirm"],
  });

type Form = Partial<z.infer<typeof formSchema>>;

const form = ref<Form>({
  password: undefined,
  confirm: undefined,
});
</script>

<template>
  <ZodForm :schema="formSchema" :value="form">
    <ZodFormField v-slot="{ setDirty, errorMessages }" :path="['password']">
      <section>
        <label for="password">Password</label>
        <input
          v-model="form.password"
          type="password"
          placeholder="Password"
          @blur="setDirty(true)"
        />
        <ul>
          <li v-for="message in errorMessages" :key="message">
            {{ message }}
          </li>
        </ul>
      </section>
    </ZodFormField>
    <ZodFormField v-slot="{ setDirty, errorMessages }" :path="['confirm']">
      <section>
        <label for="confirm">Confirm Password</label>
        <input
          v-model="form.confirm"
          type="password"
          placeholder="Confirm Password"
          @blur="setDirty(true)"
        />
        <ul>
          <li v-for="message in errorMessages" :key="message">
            {{ message }}
          </li>
        </ul>
      </section>
    </ZodFormField>
  </ZodForm>
</template>

In the above example code, within the <script> section, we define the schema, get the form type by z.infer<> and then create an instance of a form object out of it. I would say these are the 3 basic ingredients when designing the form. It provides a clean and structured convention of how one would setup a form with validation and its type, all at a glance which improves code readability.

In the <template> section, we wrap the html form with the <ZodForm> and <ZodFormField> component. We will look at their internals later so for now, we can see that we are able to use any type of HTML input element by wrapping them inside the <ZodFormField> component. The <ZodFormField> component just exposes slot props for us to bind to, allowing us to show the error messages per-field for example. If we would like to show a summary of error messages, then we can also expose the error messages via slot props at the <ZodForm> level like this:

<ZodForm :schema="formSchema" :value="form" v-slot="{ errorMessages }">
    ...
    <ul>
        <li v-for="message in errorMessages" :key="message">
        {{ message }}
        </li>
    </ul>
</ZodForm>

ZodForm component

The <ZodForm> component is pretty straightforward in its implementation. The role of this component is to call safeParse on the form instance whenever there is a change in the form field values. The result of safeParse is shared to its <ZodFormField> children components via Vue’s provide-inject mechanism.

<script setup lang="ts">
import { computed, provide } from "vue";
import type { ZodSchema } from "zod";

import { ZOD_FORM_KEY } from "./share";
const props = defineProps<{
  schema: ZodSchema;
  value: unknown;
}>();

const result = computed(() => {
  return props.schema.safeParse(props.value);
});

const isFormValid = computed(() => {
  return result.value.success;
});

const errorMessages = computed(() => {
  if (isFormValid.value) {
    return [];
  }

  return result.value.error?.issues.map((issue) => issue.message) ?? [];
});

provide(ZOD_FORM_KEY, {
  result,
});
</script>

<template>
  <slot :is-form-valid="isFormValid" :error-messages="errorMessages" />
</template>

In addition, this component also provides a helpful slot prop isFormValid boolean flag to bind to a Submit button for enabling or disabling for example.

ZodFormField component

The <ZodFormField> component is also quite straightforward in its implemenation. The role of this component is the show the respective error message from the result of safeParse from the parent <ZodForm> component with the addition of being behind a dirty boolean flag. The idea of the dirty flag is to not show the error messages until the end user’s attempt of interacting with the input element.

<script setup lang="ts">
import { computed, inject, ref } from "vue";

import { assertIsDefined } from "@/share/utils";

import { ZOD_FORM_KEY } from "./share";

const props = defineProps<{
  path: string[];
  dirty?: boolean;
}>();

const isDirty = ref(props.dirty);

const zodFormContext = inject(ZOD_FORM_KEY);
assertIsDefined(
  zodFormContext,
  "<ZodFormField> component must be used within a <ZodForm>"
);

const { result } = zodFormContext;

const errorMessages = computed(() => {
  if (!isDirty.value) {
    return [];
  }

  if (result.value.success) {
    return [];
  }

  const issues = result.value.error.issues.filter((issue) => {
    return issue.path.join(".") === props.path.join(".");
  });

  if (issues.length === 0) {
    return [];
  }

  return issues.map((issue) => issue.message);
});

const isValid = computed(() => {
  return errorMessages.value.length === 0;
});

function setDirty(value: boolean) {
  isDirty.value = value;
}
</script>

<template>
  <slot
    :set-dirty="setDirty"
    :error-messages="errorMessages"
    :is-valid="isValid"
    :is-dirty="isDirty"
  />
</template>

The isDirty flag

The isDirty boolean can be triggered by different ways, in the first example code you see earlier, you can see that it is triggered by the @blur event of the <input> element. There are times where we want it to trigger by a debounce instead for example. Therefore this flexibility of setting the dirty flag is up to the consumer of how it will be triggered.

The flag is also exposed as a prop in this component. This is for the use case when the form is in a “edit” mode, where the form fields are already populated and there is no need to have a non-dirty state, which in this case you would set the isDirty prop to true on initialization.

The other files

The types.ts and share.ts files should be pretty self explanatory. Included here for convenience.

// types.ts
import type { ComputedRef } from "vue";
import type { SafeParseReturnType, ZodRawShape } from "zod";

export type ZodFormContext = {
  result: ComputedRef<SafeParseReturnType<ZodRawShape, ZodRawShape>>;
};
// share.ts
import type { InjectionKey } from "vue";

import type { ZodFormContext } from "./types";

export const ZOD_FORM_KEY = Symbol("ZodForm2") as InjectionKey<ZodFormContext>;

Demo

As I did not want you, the audience, to download the Zod library in this blog while looking at the demo, I created on StackBlitz instead: https://stackblitz.com/edit/vitejs-vite-nss1qu.

Conclusion

Creating a standard way of defining a form and its validation for your development team is so essential especially when your project has to deal with many forms. The Zod library is an excellent library to enforce this standardization and together with the above ZodForm and ZodFormField components, it should make form implemenation easier and standardized across your team.