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

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 use zodForm$ 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>
  );
});

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!

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • fabian-hiller
  • igorbabko
  • RaeesBhatti
  • uceumice
  • Benny-Nottonson
  • mrhoodz