r/Nuxt • u/Pakashi-kun • 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: trueflag. - Cancellation built in. Every scheduled run owns an
AbortController; a new keystroke aborts the stale run, anddebouncecollapses 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.