Modular Forms
Modular Forms is a type-safe form library built natively on Qwik. The headless design gives you full control over the visual appearance of your form. The library takes care of state management and input validation.
To get started, install the @modular-forms/qwik
package:
pnpm install @modular-forms/qwik
npm install @modular-forms/qwik
yarn add @modular-forms/qwik
bun install @modular-forms/qwik
Define your form
Before you start creating a form, you define the structure and data types of the fields. Besides strings, Modular Forms can also handle booleans, numbers, files, dates, objects and arrays.
type LoginForm = {
email: string;
password: string;
};
Since Modular Forms supports Valibot and Zod for input validation, you can optionally derive the type definition from a schema.
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.'),
),
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.'),
),
});
type LoginForm = v.InferInput<typeof LoginSchema>;
If you're wondering why this guide favors Valibot over Zod, I recommend reading this announcement post.
Set initial values
After you have created the type definition, continue with the initial values of your form. To do this, create a routeLoader$
and use as generic your previously created type.
export const useFormLoader = routeLoader$<InitialValues<LoginForm>>(() => ({
email: '',
password: '',
}));
Instead of empty strings, in routeLoader$
you can also query and pass values from your database. Based on the passed object, the store of your form will be initialized to enable Qwik to reliably pre-render your website on the server. The initial values are also used later to check if the value of a field has changed after user input.
Create a form
To create a form, you use the useForm
hook. It returns the store of your form and an object with a Form
, Field
and FieldArray
component. As a parameter you pass an object to useForm
, with the previously created loader.
export default component$(() => {
const [loginForm, { Form, Field, FieldArray }] = useForm<LoginForm>({
loader: useFormLoader(),
});
});
You can use the loginForm
object to access the current state of your form. Furthermore, you can pass it to various methods provided by the library, such as reset
or setValue
, to make manual changes to the state.
In the JSX part of your component you continue with the Form
component. It encloses the fields of your form and through its properties you can define what happens when the form is submitted.
export default component$(() => {
const [loginForm, { Form, Field, FieldArray }] = useForm<LoginForm>({
loader: useFormLoader(),
});
return <Form>โฆ</Form>;
});
Add form fields
Now you can proceed with the fields of your form. With the Field
and FieldArray
component you register a field or field array. Both components are headless and provide you direct access to their current state. The second parameter of the render prop must be passed to an <input />
, <select />
or <textarea />
element to connect it to your form.
<Form>
<Field name="email">
{(field, props) => (
<input {...props} type="email" value={field.value} />
)}
</Field>
<Field name="password">
{(field, props) => (
<input {...props} type="password" value={field.value} />
)}
</Field>
<button type="submit">Login</button>
</Form>
This API design results in a fully type-safe form. Furthermore, it gives you full control over the user interface. You can develop your own TextInput
component or connect a pre-built component library.
Input validation
One of the core functionalities of Modular Forms is input validation. You can use a Valibot or Zod schema for this or our internal validation functions. To keep this guide simple, we use the Valibot schema we created earlier and pass it to the useForm
hook.
valiForm$
is an adapter that converts Valibot's error messages to the format expected by Modular Forms. For Zod usezodForm$
instead.
const [loginForm, { Form, Field, FieldArray }] = useForm<LoginForm>({
loader: useFormLoader(),
validate: valiForm$(LoginSchema),
});
Now you only need to display the error messages of your fields in case of an error.
<Field name="email">
{(field, props) => (
<div>
<input {...props} type="email" value={field.value} />
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>
Handle submission
In the last step you only have to access the values via a function when submitting the form to process and use them further. You can use formAction$
for this or the onSubmit$
property of the Form
component.
export const useFormAction = formAction$<LoginForm>((values) => {
// Runs on server
}, valiForm$(LoginSchema));
export default component$(() => {
const [loginForm, { Form, Field }] = useForm<LoginForm>({
loader: useFormLoader(),
action: useFormAction(),
validate: valiForm$(LoginSchema),
});
const handleSubmit = $<SubmitHandler<LoginForm>>((values, event) => {
// Runs on client
});
return (
<Form onSubmit$={handleSubmit}>
โฆ
</Form>
);
});
Final form
If we now put all the building blocks together, we get a working login form. Below you can see the assembled code and try it out in the attached sandbox.
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-unused-vars */
import { $, component$, type QRL } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import type { InitialValues, SubmitHandler } from '@modular-forms/qwik';
import { formAction$, useForm, valiForm$ } from '@modular-forms/qwik';
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email address is badly formatted.'),
),
password: v.pipe(
v.string(),
v.nonEmpty('Please enter your password.'),
v.minLength(8, 'Your password must have 8 characters or more.'),
),
});
type LoginForm = v.InferInput<typeof LoginSchema>;
export const useFormLoader = routeLoader$<InitialValues<LoginForm>>(() => ({
email: '',
password: '',
}));
export const useFormAction = formAction$<LoginForm>((values) => {
// Runs on server
}, valiForm$(LoginSchema));
export default component$(() => {
const [loginForm, { Form, Field }] = useForm<LoginForm>({
loader: useFormLoader(),
action: useFormAction(),
validate: valiForm$(LoginSchema),
});
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $((values, event) => {
// Runs on client
console.log(values);
});
return (
<Form onSubmit$={handleSubmit}>
<Field name="email">
{(field, props) => (
<div>
<input {...props} type="email" value={field.value} />
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>
<Field name="password">
{(field, props) => (
<div>
<input {...props} type="password" value={field.value} />
{field.error && <div>{field.error}</div>}
</div>
)}
</Field>
<button type="submit">Login</button>
</Form>
);
});
Dealing With Props
Sometimes, you may need to initialize or update the state of your form from the parent component via props. In such cases, you can use the setValue function, as illustrated in the example below:
import { setValue, useForm } from "@modular-forms/qwik";
export interface FormProps {
login?: LoginForm
}
export default component$<FormProps>((props) => {
const [loginForm, { Form, Field }] = useForm<LoginForm>({
// rest of the code...
});
useTask$(({ track }) => {
const login = track(() => props.login);
if (!login) return;
for (const [key,value] of Object.entries(login)) {
setValue(loginForm, key, value);
}
});
// rest of the code...
});
By tracking props.login with useTask$, the form fields are dynamically updated whenever the parent provides new values.
Summary
You have learned the basics of Modular Forms and are ready to create your first simple form. For more info and details you can find more guides and the API reference on our website: modularforms.dev
Do you like Modular Forms so far? It would be a great honor for us to get a star from you on GitHub!