Formstate is a small vue3 library that makes it easy to handle your form state and perform validations. It features:
- A small footprint (2.7kb gzip)
- No tight coupling with inputs, you have all the liberty to build your own inputs
- Full Typescript support, with returned fields typed based on your initial state
- Easy zod or other validation libraries support
- You can directly manipulate your field values (e.g. value, dirty, focused etc)
- Shared state of useForm -> you can reuse the same form in another component by naming your form
- Async validation support
- Custom error objects, you can return any object from a validation rule
For a vue 3 project:
npm install @formstate/core
yarn add @formstate/core
pnpm add @formstate/core
For nuxt:
npm install @formstate/core @formstate/nuxt
yarn add @formstate/core @formstate/nuxt
pnpm add @formstate/core @formstate/nuxt
And add the @formstate/nuxt package to your modules in nuxt.config.ts:
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ["@formstate/nuxt"],
});
<script lang="ts" setup>
import { useForm } from "@formstate/core";
const { someText, formState } = useForm({
someText: "initial value",
});
</script>
<template>
<input type="text" v-model="someText.value" />
</template>
<div>Input is dirty: {{ someText.dirty }}</div>
<div>Input is valid: {{ someText.valid }}</div>
<div>Complete state: {{ formState }}</div>
The formState ref contains the complete state of the form:
console.log(formState.value)
// result:
{
"context": {},
"dirty": false,
"fields": {
"someText": {
"blur": [Function],
"dirty": false,
"errors": [],
"focus": [Function],
"focused": false,
"name": "someText",
"pending": false,
"reset": [Function],
"touched": false,
"valid": true,
"validate": [Function],
"value": "initial value",
},
},
"initialFields": {
"numberInput": {
"rules": [Function],
"value": 2,
},
"someText": "hello",
},
"pending": false,
"touched": false,
"valid": true,
}
We can also get all the inputs with their values from the form as a ref:
const { values } = useForm({
someText: "initial value",
checkboxes: ["Jack"],
});
console.log(values.value);
// output: { "someText": "initial value", "checkbox": ["Jack"] }
Most values you return from a rule function means that the field is invalid. If you return undefined, null, empty array or true, it will be considered valid.
// if you return false, it will generate an error message of 'not valid'
const isRequired = (value: string) => !!value;
function rule(value: string, fieldName: string) {
if (value.length > 5) {
return "requires more than 5 characters";
}
// or return array
if (value.length > 5) {
return ["requires more than 5 characters", "some other error"];
}
// or any custom object
if (value.length > 5) {
return { error: "requires more than 5 characters", code: "too short" };
}
}
// or do something like this
function rule(value: string) {
let errors: string[] = [];
if (value.length < 5) {
errors.push("min 5 chars");
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors.push("invalid email");
}
return errors;
}
We can use the setRules function to add rules in one go:
const { someRadio, someTextInput, formState, setRules } = useForm({
someText: ""
someRadio: "John",
});
setRules( {
someTextInput: [isRequired, validateIfJohn],
someRadio: [validateIfJohn],
});
You can also set the rules directly on the fields:
import { isRequired, isNotJohn } from '../some-validations'
const { someRadio } = useForm({
someText: ""
someRadio: "John",
});
someText.rules = [isRequired, isNotJohn]
</script>
<template>
<label for="John">John</label>
<input id="John" type="radio" v-model="someRadio.value" value="John" />
<label for="John">Jack</label>
<input id="Jack" type="radio" v-model="someRadio.value" value="Jack" />
<div>Error messages: {{ someRadio.errors }}</div>
</template>
We can add a rule later on as well:
// set a new array on .rules, push doesnt work
someText.rules = [...someText.rules, someOtherValidation];
- If no rule is present, the field is considered valid
- By default rules are validated when a field value changes
- You can customize when a validation is performed, see // TODO add link to custom validation behaviour
Sometimes we want to validate a group of fields, or have rules depend on each other. You can set the formRules property on the formState object to do this. Form rules work in addition to field rules and produce extra errors.
const formRule = () => {
if (someTextInput.value === "John" && someRadio.value === "John") {
return "John is not allowed on both fields";
}
};
formState.value.formRules = [formRule];
// or
setRules({
someTextInput: [required],
formRules: [formRule],
});
Because validations are just simple functions, you can use any validation library you'd like
const MyFormValidation = z.object({
someTextInput: z.string().min(1),
someRadio: z.string(),
someRange: z.union([z.string(), z.number()]),
});
type MyForm = z.infer<typeof MyFormValidation>;
// formState type is defered from zod
const { formState, setRules } = useForm<MyForm>({
someTextInput: "",
someRadio: "John",
someRange: 5,
});
const zodValidation = (value: unknown, name: string) => {
// we pick the value from zod, as we don't want to validate whole formstate
const result = ZodType.pick({ [name]: true }).safeParse({ [name]: value });
if (!result.success) {
return result.error.errors;
}
};
setRules({
someTextInput: [zodValidation],
someRadio: [zodValidation],
someRange: [zodValidation],
});
formstate has async validation built in. You can add an async function to a field or use async validation as a form rule.
const userNameExists = async (value: string) => {
const exists = await callToApi(value);
if (exists) {
return "Username already exists";
}
};
setRules({ username: [userNameExists] });
Most often don't want to do a request on each change of a text field, as it would mean many calls to your api. You can debounce a rule, as long as the rule always returns a promise.
import debounce from "debounce-promise";
const someAsyncValidation = debounce(async (value: string) => {
// if length is less than 5 characters, don't do a request
if (value.length < 5) {
return "username should be longer than 5 characters";
}
const exists = await callToApi(value);
if (exists) {
return "Username already exists";
}
}, 500);
setRules({ username: [someAsyncValidation] });
By default, validations on a field are performed when the field value changes. You can turn off this behaviour and write your own implementation when validation is performed:
setRules({
someTextInput: [{ rule: someAsyncValidation, autoValidate: false }],
});
watch(
() => someTextInput.focused,
async () => {
if (!someTextInput.focused) {
await someTextInput.validate();
}
}
);
TODO
const { someText, setFields } = useForm({
someText: "",
});
// editing .value will change dirty state and perform validation
someText.value = "hello world!";
// using setFields function will not change dirty or perform validation
setFields({ someText: "hello world!" });
// you can also change this behavior:
setFields({ someText: "hello world!" }, { setDirty: true, validate: true });
// other values can also be changed
someText.dirty = true;
someText.touched = true;
someText.focused = true;
someText.pending = true;
someText.valid = true;
const { formState } = useForm({
someText: "",
});
formState.value.dirty = true;
formState.value.pending = true;
formState.value.touched = true;
formState.value.valid = true;
Often we would like to populate a form from an async request. The easiest way is to wrap the component were the form is located in a Suspense and async/await for the data:
const data: ResponseType = await anAsyncRequest();
const { someInput } = useForm(data);
We can also use the setFields function to later update the data
const { numberInput, textInput, setFields } = useForm({
numberInput: 0,
textInput: "",
});
onMounted(async () => {
// data would be something like { numberInput: 6, textInput: 'hello world' }
const data: ResponseType = await anAsyncRequest();
setFields(data);
});
Every input has touched and boolean properties. But in order to automatically update these properties we need to attach the input events to the formState. Note that in some browsers (looking at you safari), some types of inputs (e.g. radio) do not trigger the focus events.
<script lang="ts" setup>
import { useForm, Input } from "@formstate/core";
const { someText } = useForm({
someText: "initial value",
});
</script>
<template>
<input
type="text"
v-model="someText.value"
@focus="someText.focus"
@blur="someText.blur"
/>
<div>Input is focused: {{ someText.focused }}</div>
<div>Input has been touched: {{ someText.touched }}</div>
</template>
Submitting your form can be as simple as:
const { values } = useForm({ someTextInput: "hello" });
async function submit() {
const { valid, errors, errorFields } = await validateForm();
if (valid) {
await yourPostRequest(values);
}
}
<template>
<button @click="sumbit">Submit</button>
</template>
Or you can choose to use the <form> element:
<template>
<form @submit.prevent="sumbit">
<button>Submit</button>
</form>
</template>
To reset the complete form to its initial state, you can use the resetForm function:
const { resetForm } = useForm({ someText: "" });
<template>
<button @click="resetForm">Reset form</button>
</template>
const { someText } = useForm({ someText: "" });
<template>
<button @click="someText.reset">Reset text field</button>
</template>
In order to dynamically create form elements you can add an array as input
const { someArray } = useForm({
someArray: [
{ name: "somename", email: "" },
{ name: "", email: "" },
]});
<fieldset v-for="(v, i) of someArray.value" :key="i">
<legend>array</legend>
<input
type="text"
v-model="v.name"
/>
<input
type="text"
v-model="v.email"
/>
</fieldset>
You can directly modify the array (push, pop). But in order to not directly trigger validation everytime, it is better to update using the setFields function.
function addToArray() {
setFields({
someArray: [...someArray.value, { name: "", email: "" }],
});
}
function remove(index: number) {
setFields({
someArray: someArray.value.filter((_, i) => i !== index),
});
}
<fieldset v-for="(v, i) of someArray.value" :key="i">
<legend>array</legend>
<input
type="text"
v-model="v.name"
/>
<input
type="text"
v-model="v.email"
/>
<button @click="remove(i)">Remove</button>
</fieldset>
<button @click="addToArray">Add to array</button>
const ZodType = z.object({
someArray: z.array(
z.object({
name: z.string().min(3),
email: z.string().email(),
})
),
});
const zodValidation = (value: unknown, name: string) => {
// we pick the value from zod, as we don't want to validate whole formstate
const result = ZodType.pick({ [name]: true }).safeParse({ [name]: value });
if (!result.success) {
return result.error.errors;
}
};
setRules({
someArray: [zodValidation],
});
<fieldset v-for="(v, i) of someArray.value" :key="i">
<legend>array</legend>
<input
type="text"
v-model="v.name"
:class="{
invalid: someArray.errors.filter((e) => e.path[1] === i && e.path[2] === 'name').length > 0,
}"
/>
<input
type="text"
v-model="v.email"
:class="{
invalid: someArray.errors.filter((e) => e.path[1] === i && e.path[2] === 'email').length > 0,
}"
/>
</fieldset>
Formstate is built to reuse your form logic in different components! Simply add add a name to your form. If you use a form in another component and you want to infer types automatically, you should add add a type for the form structure:
// App.vue
import { useForm } from "@formstate/core";
const { someText, formState } = useForm("my-form", {
someText: "initial value",
});
// SomeComponent.vue
type Form = {
someText: string;
};
const { someText } = useForm<Form>("my-form");