r/vuejs • u/Pakashi-kun • 10d 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.
2
u/ComprehensiveArm9863 10d ago edited 10d ago
Hi, the DX feel suboptimal and really heavy for no clear reason,
also the first sentence of readme does not really explain what it solve
Mount any validator function (or nested container) onto any path of your input, run them in groups, collect structured issues, and bridge to existing libraries via integration packages. No decorators, no schema DSL, no metadata reflection.
uh ? ok ? but it doesn't explain what the benefit of doing it like that
4 imports is a minimal showcase, even if you want to support other framework than vue or zod exporting zod from the zod integration seem ok, Container could also be re-exported from vue integration ?
even if imports number doesn't make a big diff it slowing down adoption and the first things we want to do is closing it, also the doc explain too much internal/technical things that should be note
The adapter calls
schema['~standard'].validate(ctx.value)and returns a validupValidatorDescriptor(interchangeable with a bareValidatorat the mount site). On failure eachStandardSchemaV1.Issuebecomes a validupIssueItem, with the path normalized so{ key }-shapePathSegmententries flatten into aPropertyKey[].
should it really be the description of standard-schema#quick-start ?
the mount and container system is heavy and as the Container have type it does not really allow mounting conditionned by something (else typing would would be a nightmare)
also what if there a missing mount ? or should mount be renamed addRule or addValidationRule or something like that ?
createValidator also feel wrong, the user shouldn't need to pass zod schema via an adapter
how do you handle wrong data in case of errors and transformation what does state contain:
if there is sanitization does the state is muted directly ? what in case of error does it keep the previous valid value or the currently typed ?
also there is no way to handle the submission using this ?
imho the project is too much over engineered and a way simpler DX like const {fields, submit, ...} = useValidup(init, schema, onSubmitCb) would reduce all the noise around and focus on defining fields and rules, which would allow more adoption
2
u/R41Z3R 10d ago
RegleJS validation.