Adding validation to your Petite Vue form in Astro with Valibot

Wednesday, 23 April 2025

A couple of weeks ago I created a post about adding interactivity to a form and a reader got in touch (Thanks Roman!) asking how we could add validation to the form. I was thinking about appending it to the previous post,but thought it merited it’s own more focused post.

What we had previously

For a little recap, here is the form from the previous post.

---
import Input from "@components/astro/Input.astro";
import Label from "@components/astro/Label.astro";
import Textarea from "@components/astro/Textarea.astro";
import SpinnerGap from "@icons/spinner-gap.svg";

import { spring } from "motion";
---

<form
  id="book-form-demo"
  class:list={[
    "recommend-section relative rounded-2xl bg-white shadow-xl border border-white p-6 flex flex-col gap-3 w-full max-w-[460px] sm:min-w-[440px] overflow-hidden",
  ]}
>
  <div
    v-cloak
    class="absolute z-1 inset-0 bg-white flex flex-col items-center justify-center gap-1 p-4 sm:p-6 text-center"
    :class="isSuccess ? 'opacity-100' : 'opacity-0 pointer-events-none'"
    style={{
      transition: `opacity 0.2s ease-out`,
    }}
  >
    <div
      class="flex flex-col items-center gap-1"
      :class="isSuccess ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-30'"
      style={{
        transition: `transform  ${spring(0.3, 0.3)}, opacity 0.2s ease-out`,
      }}
    >
      <h2 class="text-xl font-medium">Success!</h2>
      <span class="text-sm opacity-90">
        Thank you for your recommendation! I look forward to checking it out and
        sharing my thoughts on it :)
      </span>

      <button
        type="button"
        class="py-2 px-3 bg-zinc-900 text-white rounded-md text-sm font-medium mt-4 w-fit"
        aria-label="Close the success message"
        @click="isSuccess = false"
      >
        Close
      </button>
    </div>
  </div>

  <Label>Your name</Label>
  <Input name="name" placeholder="Full name" />

  <Label
    >Email <span class="text-sm text-zinc-600"
      >(so I can share some appreciation!)</span
    >
  </Label>
  <Input name="email" type="email" placeholder="Email" />

  <input hidden type="text" name="honeypot" value="" />

  <Label>Your recommendation</Label>
  <Textarea
    name="recommendation"
    placeholder="Share a book (I'd also love to hear why you recommend it or love it so much!)"
  />

  <button
    type="submit"
    class="w-full py-2 px-3 bg-zinc-900 text-white rounded-md text-sm font-medium mt-3 disabled:opacity-40 disabled:cursor-not-allowed"
    :disabled="isPending"
    aria-label="Submit your recommendation"
  >
    <span
      v-cloak
      v-if="isPending"
      class="flex justify-center items-center gap-2"
      ><SpinnerGap class="text-white size-4 animate-spin" /> Sending</span
    >
    <span v-else> Send your recommendation </span>
  </button>
</form>

<script>
  import { CustomEventNames } from "../../../utils/constants";
  // @ts-ignore
  import { createApp } from "petite-vue";

  // this allows us to mount the app on page navigation
  document.addEventListener(CustomEventNames.ANIMATE_CONTENT_IN, async () => {
    const appState = {
      isPending: false,
      isSuccess: false,
      pending() {
        this.isPending = true;
      },
      success() {
        this.isSuccess = true;
        this.isPending = false;
      },
      reset() {
        this.isPending = false;
        this.isSuccess = false;
      },
    };

    createApp(appState).mount("#book-form-demo");

    const form = document.querySelector("#book-form-demo");

    form?.addEventListener("submit", async (event) => {
      event.preventDefault();

      appState.pending();

      await new Promise((resolve) => setTimeout(resolve, 500));

      appState.success();
    });
  });
</script>

<style>
  [v-cloak] {
    display: none;
  }
</style>

Adding Valibot for validation

For validation I would normally reach for Zod but to keep in line with the ethos of the site I went for Valibot to keep the size down. Valibot is a runtime validation library that is type safe, has a small footprint and is very easy to use. We can also import each validation rule individually so we don’t have to pull in the whole library if we don’t need it.

<script>
  import { CustomEventNames } from "../../../utils/constants";

  import {
    object,
    string,
    pipe,
    minLength,
    maxLength,
    email,
    safeParse,
    flatten,
  } from "valibot";
  import { createApp } from "petite-vue";

  const BookFormSchema = object({
    name: pipe(
      string(),
      minLength(1, "Please enter your name"),
      maxLength(100, "Name is too long")
    ),
    email: pipe(string(), email("Please enter a valid email address")),
    recommendation: pipe(
      string(),
      minLength(1, "Please share your book recommendation"),
      maxLength(1000, "Recommendation is too long")
    ),
  });

  type BookFormSchemaType = typeof BookFormSchema;

  // this allows us to mount the app on page navigation
  document.addEventListener(CustomEventNames.ANIMATE_CONTENT_IN, async () => {
    const appState = {
      isPending: false,
      isSuccess: false,

      errors: {},
      pending() {
        this.isPending = true;
        this.errors = {};
      },
      success() {
        this.isSuccess = true;
        this.isPending = false;
      },
      reset() {
        this.isPending = false;
        this.isSuccess = false;
        this.errors = {};
      },

      validateForm(formData: Record<string, FormDataEntryValue>) {
        this.errors = {};
        const result = safeParse(BookFormSchema, formData);
        if (result.success) {
          return true;
        } else {
          const flatIssues = flatten<BookFormSchemaType>(result.issues);

          for (const key in flatIssues.nested) {
            // @ts-ignore
            this.errors[key] = flatIssues.nested[key][0];
          }
        }
      },
    };

    createApp(appState).mount("#book-form-demo");

    const form = document.querySelector("#book-form-demo");

    form?.addEventListener("submit", async (event) => {
      event.preventDefault();

      const formData = Object.fromEntries(
        new FormData(event.target as HTMLFormElement)
      );

      if (!appState.validateForm(formData)) {
        return;
      }

      appState.pending();

      await new Promise((resolve) => setTimeout(resolve, 500));

      appState.success();
    });
  });
</script>

What’s happening in our script?

  • We import the Valibot library and the object, pipe, minLength, maxLength, email, safeParse, flatten functions.
  • We define the schema for the form.
  • We define the type for the schema.

The above is basically the setup for the form validation using Valibot. The next step is adding the error state and validation method to the app state.

  • We add the errors state to the app state.
  • We add the validateForm method to the app state.

The last step is adding the validation to the submission of the form.

form?.addEventListener("submit", async (event) => {
  event.preventDefault();

  const formData = new FormData(event.target);

  if (!appState.validateForm(formData)) {
    return;
  }

  appState.pending();

  await new Promise((resolve) => setTimeout(resolve, 500));

  appState.success();
});

As you can see we are using the new FormData(event.target) to get the form data and then passing it to the validateForm method.

What’s happening in the form?

  • We are using the new FormData(event.target) to get the form data.
  • We are passing the form data to the validateForm method.
  • If the form data is invalid, we set the error state.
  • In the UI we surface the error elements for the corresponding input

Here’s an example of one of the input fields with the error state being added. In this case we render the error state if the errors.recommendation state is true.

<div class="flex flex-col gap-1">
  <Label>Your recommendation</Label>
  <Textarea
    name="recommendation"
    placeholder="Share a book (I'd also love to hear why you recommend it or love it so much!)"
    :class="errors.recommendation ? 'border-red-500' : ''"
  />
  <span
    v-if="errors.recommendation"
    class={errorClass}
    v-text="errors.recommendation"></span>
</div>

Below you can play with the form and see the validation in action.

This is a demo, submissions won’t be sent.

Success!

Thank you for your recommendation! I look forward to checking it out and sharing my thoughts on it :)

After building the site and heading into the network tab, the whole script currently weighs in at 2.4kb (minified and gzipped).

This has been a brief post on how we can add validation to a form using Valibot and Petite Vue. I hope this has been useful and if you have any questions or suggestions please do let me know!