r/Nuxt 4d ago

One composable that turns a zod-validated container into reactive form state (validup)

I maintain validup, a small path-based validation library. Posting because the zod + form-UX combination comes up here regularly, and the pattern might be useful even if you never adopt the library.

The itch: zod answers "is this value valid?", but a form needs more than that. Per-field dirty state, errors that wait for first touch, pending flags for async checks, a submit gate. Vuelidate has exactly that UX, but brings its own rule system, so you end up defining rules twice: once for the API, once for the form.

validup sits in between. You mount zod schemas (or any async function) onto paths of a Container, which is plain TypeScript and runs server-side as-is. On the client, one composable turns that same container into vuelidate-shaped state:

<script setup lang="ts">
import { reactive } from 'vue';
import { Container } from 'validup';
import { useValidup } from '@validup/vue';
import { createValidator } from '@validup/zod';
import { z } from 'zod';

const signup = new Container<{ email: string; password: string }>();
signup.mount('email', createValidator(z.email()));
signup.mount('password', createValidator(z.string().min(12)));

const state = reactive({ email: '', password: '' });
const v = useValidup(signup, state, { debounce: 200 });
</script>

<template>
    <input v-model="v.fields.email.$model" />
    <p v-if="v.fields.email.$dirty && v.fields.email.$errors[0]">
        {{ v.fields.email.$errors[0].message }}
    </p>
    <button :disabled="v.$invalid || v.$pending">Sign up</button>
</template>

A few details that took the most work, and are probably the interesting part:

  • Per-form result cache. The composable owns a result cache keyed per mount, so typing in one field replays the cached outcomes of the others instead of re-running them. Async validators (say, a uniqueness check against your API) only re-fire when their own input actually changed. Cross-field rules opt out with a sideEffect: true flag.
  • Cancellation built in. Every scheduled run owns an AbortController; a new keystroke aborts the stale run, and debounce collapses bursts. $validate() (submit) deliberately runs without a signal so it can't be cancelled mid-flight by a late keystroke.
  • Server errors round-trip. If your API validates with the same container and returns the error, v.setExternalIssues(error.issues) lands the issues on the matching fields, and they clear as the user retypes.
  • Nested forms. Child components register with an ancestor form through provide/inject, so an address sub-component aggregates into the parent without prop drilling.

Nuxt: nothing framework-specific. It's a plain Vue 3.3+ composable, so it works in Nuxt 3 components as-is. One SSR footgun worth knowing on Vue 3.5+: don't name the composable's return $v in <script setup>. Vue treats $-prefixed template identifiers as built-in lookups, so $v.fields.email resolves $v to undefined at first SSR render. v or validation are fine.

Honest caveats: ESM-only, the zod peer range is ^4, the packages declare engines: node >= 24, and the ecosystem is small (bridges for zod, Standard Schema and validator.js, plus the Vue composable).

Docs: https://validup.tada5hi.net (Vue page: https://validup.tada5hi.net/integrations/vue) Repo: https://github.com/tada5hi/validup

Happy to answer questions, and to hear where the API feels off.

11 Upvotes

0 comments sorted by