Skip to content

Forms

Forms allow users to fill and submit data to your application, sometimes they are given feedback along the way to help them achieve that.

Assuming you’ve followed the other guides and have created a few input components, you can now use them to build a form. Formwerk builds on the native form element but it is not required.

Features

  • Value tracking and submission handling.
  • Nested fields and arrays support.
  • Controlled and uncontrolled fields.
  • Multi-layered validation with both HTML attributes or Standard Schemas.
  • Aggregated state for validation, dirty, touched, and more.
  • Type safety for form data and submitted data.
  • Scrolling to the first invalid field after submission.
  • Submitted data can be consumed as a plain object, JSON, or FormData object.

useForm

You will be using the useForm composable to create a form context in the current component. This effectively marks the component as a form, meaning you can only use useForm once per component.

This is the most basic form you can create with Formwerk:

import { useForm } from '@formwerk/core';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
console.log(data);
});

It doesn’t look like much, but already a lot is being done for you behind the scenes. The useForm composable creates a reactive form context that does the following among other things:

  • Tracks and collects the values of all input fields within the form.
  • Tracks the validity of each field and the overall form validity.
  • Provides a handleSubmit function that you can use to submit the form.

Here is an example with some input fields we already created from the previous guides:

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<Checkbox label="Remember me" name="rememberMe" />
<button type="submit">Submit</button>
</form>
</template>

Controlled Fields

You may have noticed that we passed the name prop to the input fields in the previous example. This is because Formwerk uses the name prop to identify the fields in the form and uses it to build the form data object that will eventually be submitted.

Passing the name prop to the field marks it as “controlled”, as in it is being tracked by the form and contributes its state and value to the form data object.

Now if you want to do the opposite, which is to not have the field be tracked by the form, then you can simply skip passing the name prop. This matches the behavior of FormData objects and native form submission behavior.

Here is an example where a non-controlled field can be useful. In this example, we toggle the visibility of the billingAddress field based on the value of the sameAsShipping field, but we don’t want to submit the latter.

<script setup lang="ts">
import { ref } from 'vue';
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const { handleSubmit } = useForm();
const isSameAsBilling = ref(false);
const onSubmit = handleSubmit((data) => {
const json = data.toObject();
if (isSameAsBilling.value) {
json.billingAddress = json.shippingAddress;
}
alert(JSON.stringify(json, null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="shippingAddress" label="Shipping Address" required />
<TextField
v-if="!isSameAsBilling"
name="billingAddress"
label="Billing Address"
required
/>
<Checkbox label="Same as shipping" v-model="isSameAsBilling" />
<button type="submit">Submit</button>
</form>
</template>

Nested Fields

Formwerk supports nested fields by using the . character in the name prop. This allows you to create nested objects in the form data object to structure your data however you need. Having numeric path names will result in arrays being created instead of objects.

Here is an example with both nested fields and arrays:

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="socials.github" label="GitHub " type="url" required />
<TextField name="socials.twitter" label="Twitter " type="url" required />
<TextField name="socials.discord" label="Discord" type="url" required />
<TextField name="customLinks.0" label="Custom link 1" type="url" />
<TextField name="customLinks.1" label="Custom Link 2" type="url" />
<button type="submit">Submit</button>
</form>
</template>

Submitting Forms

You noticed that the values of the form are collected and passed for you in the data object in the previous examples.

handleSubmit

The previous examples used the handleSubmit function to submit the form. This function doesn’t require you to use a form element nor does it require you to use it with a submit event. You can use it with any event or even call it directly.

The handleSubmit function takes a callback function that will be called with the data object when the form is submitted. The callback is run only if the form is valid; otherwise, it does nothing.

Here is an example where we just call the submission handler directly:

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
function onClick() {
onSubmit();
}
</script>
<template>
<TextField name="field" label="Your field" value="Press 👇" />
<button @click="onClick">Press me</button>
</template>

toObject

The most common way to get the form data is to call the toObject method on the data object. This method returns a plain JavaScript object with the form data as you’ve seen in the previous examples.

import { useForm } from '@formwerk/core';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
data.toObject(); // { email: '...', password: '...', rememberMe: true }
});

toJSON

The toJSON method returns a JSON-serializable object with the form data. While this often matches the structure from toObject, JSON has some limitations compared to JavaScript objects. For example, JSON cannot represent:

  • undefined values
  • Date objects (they get converted to strings)
  • File objects
  • Functions
  • BigInt values
  • Symbol values

The toJSON method handles converting these values into JSON-safe equivalents automatically.

import { useForm } from '@formwerk/core';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit(async (data) => {
const response = await fetch('https://example.org/post', {
body: JSON.stringify(data.toJSON()),
});
});

If you plan to stringify the form data for use with something like fetch or axios, then you can omit the toJSON and call JSON.stringify directly on the data object, which will call toJSON under the hood.

const onSubmit = handleSubmit(async (data) => {
const response = await fetch('https://example.org/post', {
body: JSON.stringify(data.toJSON()),
body: JSON.stringify(data),
});
});

The toJSON method is fully typed to match the JSON-serialized structure of your form data. The types will automatically handle non-serializable values like undefined, Date objects, and File objects in the type system, ensuring the types match what actually gets serialized to JSON.

toFormData

If you need to submit the form data as a FormData object, you can call the toFormData method instead.

This method returns a FormData object that you can use to submit the form data to traditional form endpoints or APIs. It becomes especially useful when submitting files since they cannot be transported in JSON.

import { useForm } from '@formwerk/core';
const { handleSubmit } = useForm();
const onSubmit = handleSubmit((data) => {
data.toFormData(); // FormData
});

formProps

If you prefer to not handle submissions with JavaScript and instead want to rely on native form submissions, which is common with non-JS server-rendered applications like Rails (Ruby) or Laravel (PHP) applications, you can use the formProps object that is returned by the useForm composable to bind the form props to the form element. It will enhance the native submission cycle with the same features as with the handleSubmit function.

When you submit a form bound to the formProps object:

  1. The form submit event will be prevented.
  2. The form data will be collected and validated.
  3. If invalid, the form will not be submitted, and the flow ends.
  4. If valid, the form will be submitted using the native form submission cycle.

Here is an example of how to use formProps. The example will submit the data to another page that will list the submitted values. Typically, your backend endpoint would be handling the form submission.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const { formProps } = useForm();
</script>
<template>
<form v-bind="formProps" target="_blank" action="/form-d">
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<Checkbox label="Remember me" name="rememberMe" />
<button type="submit">Submit</button>
</form>
</template>

Submit State

Forms and Fields expose several properties related to the submission state. They can be useful to build certain UI behaviors like showing submit progress spinners or disabling the submit button while the form is being submitted with an async handler.

isSubmitting

You can check the submission status with the isSubmitting property. This property is true when the form is being submitted and false otherwise. This is useful when you want to show a loading spinner or disable the submit button while the form is being submitted with an async handler.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit, isSubmitting } = useForm();
const onSubmit = handleSubmit(async (data) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<button :disabled="isSubmitting" type="submit">Submit</button>
</form>
</template>

wasSubmitted

The wasSubmitted property is true if the form was submitted and the handler was called without any errors thrown. This is useful when you want to show a success message or perform some custom logic after the form has been submitted.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit, wasSubmitted } = useForm();
const onSubmit = handleSubmit(async (data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form v-if="!wasSubmitted" @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<button type="submit">Submit</button>
</form>
<p v-else>Form was submitted</p>
</template>

This state is reset when the form is reset.

submitAttemptsCount

The submitAttemptsCount property returns the number of times the form has been submitted regardless of whether it was valid or not.

This can be useful when you want to disable certain UI elements, or show some feedback to the user. You might even want to gather analytics! maybe your form is too hard?

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit, submitAttemptsCount } = useForm();
const onSubmit = handleSubmit(async (data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<p>Submit attempts: {{ submitAttemptsCount }}</p>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<button type="submit">Submit</button>
</form>
</template>

This state is reset when the form is reset.

isSubmitAttempted

The isSubmitAttempted property is true if the form was submitted, but unlike wasSubmitted, it turns true even if the form is invalid.

import { useForm } from '@formwerk/core';
const { handleSubmit, isSubmitAttempted } = useForm();
const onSubmit = handleSubmit((data) => {
console.log(JSON.stringify(data.toObject(), null, 2));
});

Scrolling to invalid fields

By default, Formwerk will scroll to the first invalid field when the form is submitted.

The scrolling is performed with the Element.scrollIntoView method with smooth behavior by default. You can override that by passing ScrollViewOptions to the scrollToInvalidFieldOnSubmit option.

import { useForm } from '@formwerk/core';
useForm({
scrollToInvalidFieldOnSubmit: {
behavior: 'instant', // default is 'smooth'
block: 'center', // default is 'center'
inline: 'start', // default is 'start'
},
});

You can see it in action in the following example. Scroll all the way down to see the form submit button and then click it to see the invalid field being scrolled into view.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit, isSubmitting } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<button :disabled="isSubmitting" type="submit">Submit</button>
</form>
</template>
<style>
form {
display: flex;
flex-direction: column;
/* This is to make the view scrollable */
gap: 500px;
}
</style>

This behavior can be disabled by setting the scrollToInvalidFieldOnSubmit option to false when creating the form.

import { useForm } from '@formwerk/core';
useForm({
scrollToInvalidFieldOnSubmit: false,
});

Touched Fields

Forms track the touched state of each field in the form. A field is considered touched when the user interacts with it, which means if they have focused and blurred the field at least once. In addition to blurring, whenever the form is submitted, all fields are marked as touched.

The form also tracks the overall touched state of the form, which is true if any field in the form has been touched.

Each field composable returns an isTouched property. useForm exposes its own isTouched method that you can use to check if the form has been interacted with or if a field has been touched.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit, isTouched } = useForm();
const onSubmit = handleSubmit((data) => {
console.log('All fields should be touched now');
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" />
<TextField name="password" label="Password" type="password" />
<pre>Email Touched: {{ isTouched('email') }}</pre>
<pre>Password Touched: {{ isTouched('password') }}</pre>
<pre>Form Touched: {{ isTouched() }}</pre>
<button type="submit">Submit</button>
</form>
</template>

You can set the touched state of fields manually with the setTouched function.

import { useForm } from '@formwerk/core';
const { setTouched } = useForm();
function onFieldBlur() {
setTouched('email', true);
// Or set all fields to touched
setTouched(true);
}

Dirty Fields

Forms also track the dirty state of each field in the form. A field is considered dirty when its value has changed from the initial value. The form also tracks the overall dirty state of the form, which is true if any field in the form has been modified.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { isDirty } = useForm();
</script>
<template>
<TextField name="email" label="Email" type="email" />
<TextField name="password" label="Password" type="password" />
<pre>Email Dirty: {{ isDirty('email') }}</pre>
<pre>Password Dirty: {{ isDirty('password') }}</pre>
<pre>Form Dirty: {{ isDirty() }}</pre>
</template>

The dirty state is computed. There is no way to set it manually, but you can reset the form to its initial values or a new set of values to influence the dirty state.

Validation

As you’ve seen from field guides, many fields can be validated with either HTML constraints via attributes like required, min, max, etc., or with Standard Schema objects.

HTML Constraints

HTML constraints are always field-level. They are useful for dynamic fields, but at the same time, they are more accessible to users, which is why it is recommended to use them whenever possible for basic validations.

For advanced cases, you can use Standard Schemas, which can be both field-level or form-level.

If you want to completely disable HTML constraints for the form, you can pass the disableHtmlValidation option to useForm:

import { useForm } from '@formwerk/core';
useForm({
disableHtmlValidation: true,
});

Form-level Validation with Standard Schemas

But you can also provide a form-level Standard Schema to useForm to validate the entire form as a whole. Form-level schemas are useful for forms where the fields are known beforehand.

Here is an example of a form with a Standard Schema:

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import { z } from 'zod';
const { handleSubmit } = useForm({
schema: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
});
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" />
<TextField name="password" label="Password" type="password" />
<button type="submit">Submit</button>
</form>
</template>

For more information on Standard Schemas and which libraries are supported, visit the project’s GitHub page.

Mixing validation sources

Let’s say you have a mix of validations in place. You have a field with some HTML constraints and a schema that validates that field at the same time. Let’s throw in a form-level schema that validates the form, including that field.

That field now has three sources of validation. How does that work?

Formwerk prioritizes the validation sources in the following order:

  1. HTML Constraints are checked first. Only if they are valid, continue to the next step.
  2. Field-level Standard Schema is checked next. Only if it is valid, continue to the next step.
  3. Form-level Standard Schema is checked last.

This keeps the validation process consistent and predictable. At the same time, it is also efficient, as you won’t have to re-validate the whole form if a field-level validation fails for that field. You can think of it as a merged validation approach, but it is more of a cascading validation behavior where it cascades upwards to the form level.

The only thing you need to be careful of is to not have conflicting validations between the different sources, as this can cause the field to never be valid.

Here is an example for a field with all validation sources:

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import { z } from 'zod';
const { handleSubmit } = useForm({
schema: z.object({
field: z.string().max(8),
}),
});
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="field" label="Field" min-length="3" required />
<button type="submit">Submit</button>
</form>
</template>

You can even have a fourth source of validation with Form Groups.

Displaying Errors

If you have followed the field guides, you know that fields are responsible for displaying their own errors. But what if you want to display the form errors in a single place, or maybe you just need access to errors to perform some custom logic?

There are three ways to access errors with useForm:

  • getError to get the error of a specific field.
  • getErrors to get all errors in the form grouped by field.
  • displayError to display the error of a specific field if it has been touched.

The getError function returns the error of a specific field. If the field has no error, it returns undefined.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import { z } from 'zod';
const { handleSubmit, getError } = useForm({
schema: z.object({
url: z.string().url().max(8),
email: z.string().email(),
}),
});
</script>
<template>
<TextField name="url" label="URL" required />
<TextField name="email" label="Email" required />
<ul>
<li>URL Error: {{ getError('url') }}</li>
<li>Email Error: {{ getError('email') }}</li>
</ul>
</template>

Submit Errors

Unlike validation errors which are mostly “live” and react to the values changing regardless of when you display them, submit errors are only populated when the form is submitted. This is useful if you want to only show errors after submits rather than live.

Each field exposes submitErrorMessage and submitErrors, using these will only display errors after the form is submitted.

import { useTextField } from '@formwerk/core';
const { submitErrorMessage, submitErrors } = useTextField({
// ...
});

Forms also expose getSubmitError and getSubmitErrors to get the submit error of a specific field or all fields respectively, if you need access to them on the form level.

Resetting Forms

Form state can be reset with the reset function. Calling this function will reset the current values back to the initial values, revert the touched state for all fields back to false, and clear any custom errors.

<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
import Checkbox from './Checkbox.vue';
const { handleSubmit, reset } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
function onResetClick() {
reset();
}
</script>
<template>
<form @submit="onSubmit" novalidate>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<Checkbox label="Remember me" name="rememberMe" />
<button type="submit">Submit</button>
<button type="button" @click="onResetClick">Reset</button>
</form>
</template>

Notice that even though we called reset, the errors are still displayed. By default, reset re-validates the form afterward. This is to ensure that the validation state of the fields matches their actual validity.

You should consider using isTouched or displayError to show errors only when the field has been interacted with, which would eliminate this caveat. Alternatively, you can disable this behavior by passing revalidate: false to the reset function.

import { useForm } from '@formwerk/core';
const { reset } = useForm();
function onReset() {
reset({ revalidate: false });
}

You can also reset the form to a specific state by passing a ResetState object to the reset function. This object can contain the following properties:

  • values: The new form values.
  • touched: The new touched state for each field.
<script setup lang="ts">
import { useForm } from '@formwerk/core';
import TextField from './TextField.vue';
const { handleSubmit, reset } = useForm();
const onSubmit = handleSubmit((data) => {
alert(JSON.stringify(data.toObject(), null, 2));
});
function onResetClick() {
reset({
values: {
email: 'hello@formwerk.dev',
password: 'p@$$w0rd',
},
touched: {
email: true,
password: false,
},
});
}
</script>
<template>
<TextField name="email" label="Email" type="email" required />
<TextField name="password" label="Password" type="password" required />
<button @click="onResetClick">Reset</button>
</template>