Type-safe <form>
for React using Zod!
Features / opinions
- 💎 Type-safe
- Get form data as a typed object
- Typo-safe
name
andid
attribute generation
- 🤯 Simple nested object and array fields
- And still type-safe!
- ✅ Validation on the client and the server
- With FormData or JSON
- Eg. works with any JavaScript backend
- Remix, Next.js, Express, Node.js, CF Workers, Deno etc.
- 📦 Tiny: Less than 3kb (minified & gzipped)
- 🌳 Tree shakes to be even smaller!
- 🤷 No dependencies, only peer deps for React and Zod
- 🛑 No controlled inputs or context providers required
- ☝️ The form is validated directly from the
<form>
DOM element - 🚀 As performant as React form libraries can get!
- ☝️ The form is validated directly from the
If you enjoy this lib a Twitter shout-out @esamatti is always welcome! 😊
You can also checkout my talk at React Finland 2022. Slides.
npm install react-zorm
Also on Codesandbox!
import { z } from "zod";
import { useZorm } from "react-zorm";
const FormSchema = z.object({
name: z.string().min(1),
age: z
.string()
.regex(/^[0-9]+$/)
.transform(Number),
});
function Signup() {
const zo = useZorm("signup", FormSchema, {
onValidSubmit(e) {
e.preventDefault();
alert("Form ok!\n" + JSON.stringify(e.data, null, 2));
},
});
const disabled = zo.validation?.success === false;
return (
<form ref={zo.ref}>
Name:
<input
type="text"
name={zo.fields.name()}
className={zo.errors.name("errored")}
/>
{zo.errors.name((e) => (
<ErrorMessage message={e.message} />
))}
Age
<input
type="text"
name={zo.fields.age()}
className={zo.errors.age("errored")}
/>
{zo.errors.age((e) => (
<ErrorMessage message="Age must a number" />
))}
<button disabled={disabled} type="submit">
Signup!
</button>
<pre>Validation status: {JSON.stringify(zo.validation, null, 2)}</pre>
</form>
);
}
Also checkout this classic TODOs example demonstrating almost every feature in the library and if you are in to Remix checkout this server-side validation example.
Create a Zod type with a nested object
const FormSchema = z.object({
user: z.object({
email: z.string().min(1),
password: z.string().min(8),
}),
});
and just create the input names with .user.
:
<input type="text" name={zo.fields.user.email()} />;
<input type="password" name={zo.fields.user.password()} />;
Array of user objects for example:
const FormSchema = z.object({
users: z.array(
z.object({
email: z.string().min(1),
password: z.string().min(8),
}),
),
});
and put the array index to users(index)
:
users.map((user, index) => {
return (
<>
<input type="text" name={zo.fields.users(index).email()} />
<input type="password" name={zo.fields.users(index).password()} />
</>
);
});
And all this is type checked 👌
See the TODOs example for more details
This is Remix but React Zorm does not actually use any Remix APIs so this method can be adapted for any JavaScript based server.
import { parseForm } from "react-zorm";
export let action: ActionFunction = async ({ request }) => {
const form = await request.formData();
// Get parsed and typed form object. This throws on validation errors.
const data = parseForm(FormSchema, form);
};
The useZorm()
hook can take in any additional ZodIssue
s via the customIssues
option:
const zo = useZorm("signup", FormSchema, {
customIssues: [
{
code: "custom",
path: ["username"],
message: "The username is already in use",
},
],
});
These issues can be generated anywhere. Most commonly on the server. The error chain will render these issues on the matching paths just like the errors coming from the schema.
To make their generation type-safe react-zorm exports createCustomIssues()
chain to make it easy:
const issues = createCustomIssues(FormSchema);
issues.username("Username already in use");
const zo = useZorm("signup", FormSchema, {
customIssues: issues.toArray(),
});
This code is very contrived but take a look at these examples:
The chains are a way to access the form validation state in a type safe way.
The invocation via ()
returns the chain value. On the fields
chain the value is the name
input attribute
and the errors
chain it is the possible ZodIssue object for the field.
There few other option for invoking the chain:
Return values for different invocation types
("name"): string
- Thename
attribute value("id"): string
- Uniqueid
attribute value to be used with labels andaria-describedby
(): string
- The default, same as"name"
(index: number): FieldChain
- Special case for setting array indices(fn: RenderFunction): any
- Calls the function with{name: string, id: string}
and renders the return value.- Can be used to create resuable fields. Codesandbox example.
(): ZodIssue | undefined
- Possible ZodIssue object(value: T): T | undefined
- Return the passed value on error. Useful for setting class names for example(value: typeof Boolean): boolean
- Returntrue
when there's an error andfalse
when it is ok. Example.field(Boolean)
.<T>(render: (issue: ZodIssue) => T): T | undefined
- Invoke the passed function with theZodIssue
and return its return value. When there's no error aundefined
is returned. Useful for rendering error message components(index: number): ErrorChain
- Special case for accessing array elements
The first tool you should reach is React. Just make the input controlled with
useState()
. This works just fine with checkboxes, radio buttons and even with
text inputs when the form is small. React Zorm is not really interested how the
inputs get on the form. It just reads the value
attributes using the
platform form APIs (FormData).
But if you have a larger form where you need to read the input value and you
find it too heavy to read it with just useState()
you can use useValue()
from Zorm.
import { useValue } from "react-zorm";
function Form() {
const zo = useZorm("form", FormSchema);
const value = useValue({ zorm: zo, name: zo.fields.input() });
return <form ref={zo.ref}>...</form>;
}
useValue()
works by subscribing to the input DOM events and syncing the value
to a local state. But this does not fix the performance issue yet. You need to
move the useValue()
call to a subcomponent to avoid rendering the whole form
on every input change. See the Zorm type docs on how to do
this.
Alternatively you can use the <Value>
wrapper which allows access to the input
value via render prop:
import { Value } from "react-zorm";
function Form() {
const zo = useZorm("form", FormSchema);
return (
<form ref={zo.ref}>
<input type="text" name={zo.fields.input()} />
<Value form={zo.ref} name={zo.fields.input()}>
{(value) => <span>Input value: {value}</span>}
</Value>
</form>
);
}
This way only the inner <span>
element renders on the input changes.
Here's a codesandox demonstrating these and vizualizing the renders.
When the form submits and on input blurs after the first submit attempt.
If you want total control over this, pass in setupListeners: false
and call
validate()
manually when you need. Note that now you need to manually prevent
submitting when the form is invalid.
function Signup() {
const zo = useZorm("signup", FormSchema, { setupListeners: false });
return (
<form
ref={zo.ref}
onSubmit={(e) => {
const validation = zo.validate();
if (!validation.success) {
e.preventDefault();
}
}}
>
...
</form>
);
}
That do not create <input>
elements?
Since Zorm just works with the native <form>
you must sync their state to
<input type="hidden">
elements in order for them to become actually part of
the form.
Here's a Codesandbox example with react-select
.
See https://twitter.com/esamatti/status/1488553690613039108
Use the ZodIssue
's .code
properties to render corresponding error messages
based on the current language instead of just rendering the .message
.
See this Codesandbox example:
Checkboxes can result to simple booleans or arrays of selected values. These custom Zod types can help with them. See this usage example.
const booleanCheckbox = () =>
z
.string()
// Unchecked checkbox is just missing so it must be optional
.optional()
// Transform the value to boolean
.transform(Boolean);
const arrayCheckbox = () =>
z
.array(z.string().nullish())
.nullish()
// Remove all nulls to ensure string[]
.transform((a) => (a ?? []).flatMap((item) => (item ? item : [])));
If your server does not support parsing form data to the standard FormData
you
can post the form as JSON and just use .parse()
from the Zod schema. See the
next section for JSON posting.
Prevent the default submission in onValidSubmit()
and use fetch()
:
const zo = useZorm("todos", FormSchema, {
onValidSubmit: async (event) => {
event.preventDefault();
await fetch("/api/form-handler", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(event.data),
});
},
});
If you need loading states React Query mutations can be cool:
import { useMutation } from "react-query";
// ...
const formPost = useMutation((data) => {
return fetch("/api/form-handler", {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
});
const zo = useZorm("todos", FormSchema, {
onValidSubmit: async (event) => {
event.preventDefault();
formPost.mutate(event.data);
},
});
return formPost.isLoading ? "Sending..." : null;
Tools available for importing from "react-zorm"
Create a form Validator
The form name. This used for the input id generation so it should be unique string within your forms.
Zod schema to parse the form with.
onValidSubmit(event: ValidSubmitEvent): any
: Called when the form is submitted with valid dataValidSubmitEvent#data
: The Zod parsed form dataValidSubmitEvent#target
: The form HTML ElementValidSubmitEvent#preventDefault()
: Prevent the default form submission
setupListeners: boolean
: Do not setup any listeners. Ie.onValidSubmit
won't be called nor the submission is automatically prevented. This gives total control when to validate the form. Set your ownonSubmit
on the form etc. Defaults totrue
.customIssues: ZodIssue[]
: Any additionalZodIssue
to be rendered within the error chain. This is commonly used to handle server-side field validation
ref
: A callback ref for the<form>
elementform
: The current form element set by the callback refvalidation: SafeParseReturnType | null
: The current Zod validation status returned bysafeParse()
validate(): SafeParseReturnType
: Manually invoke validationfields: FieldChain
: The fields chainerrors: ErroChain
: The error chain
The type of the object returned by useZorm()
. This type object can be used to
type component props if you want to split the form to multiple components and
pass the zorm
object around.
import type { Zorm } from "react-zorm";
function MyForm() {
const zo = useZorm("signup", FormSchema);
return (
// ...
<SubComponent zorm={zo} />
//..
);
}
function SubComponent(props: { zorm: Zorm<typeof FormSchema> }) {
// ...
}
Get live raw value from the input.
form: RefObject<HTMLFormElement>
: The form ref fromzo.ref
initialValue: string
: Initial value on the first and ssr rendertransform(value: string): any
: Transform the value before setting it to the internal state. The type can be also changed.
Render prop version of the useValue()
hook. The props are ValueSubscription
.
The render prop child is (value: string) => ReactNode
.
<Value zorm={zo} name={zo.fields.input()}>
{(value) => <>value</>}
</Value>
Parse HTMLFormElement
or FormData
with the given Zod schema.
Like parseForm()
but uses the safeParse()
method from Zod.