- 💎 Type-safe and powered by Zod
- 🪝 Hook-based API
- 🌳 Under 3 KB (minified & gzipped), and tree-shakable
- 🗂️ Supports nested object and array fields
- 🍱 Easily control 3rd party fields
Inspired by react-hook-form and react-zorm.
Install react-zoom-form
and zod
yarn add @stevent-team/react-zoom-form zod
import { useForm } from '@stevent-team/react-zoom-form'
import { z } from 'zod'
// Define the structure and validation of your form
const schema = z.object({
name: z.string().min(1, 'Name is required'),
age: z.coerce.number().min(13),
})
const EditPage = () => {
const { fields, handleSubmit } = useForm({ schema })
const onSubmit = values => {
console.log(values)
}
return <form onSubmit={handleSubmit(onSubmit)}>
<input {...fields.name.register()} type="text" />
<input {...fields.age.register()} type="number" />
<button>Save changes</button>
</form>
}
A basic Errors
component is provided that will take a field and display comma separated error messages in a span. See the Errors
API reference for more info.
<input {...fields.name.register()} type="text" />
<Errors field={fields.name} />
There is also a fieldErrors
function you can wrap a field in to get an array of ZodIssue
s for that field. See the fieldErrors
API reference for more info.
<input {...fields.name.register()} type="text" />
{fieldErrors(fields.name).map(issue => <span>{issue.message}</span>)}
You can customize the error messages in several ways.
- Set a custom error message in your Zod schema
const schema = z.object({ description: z.string().max(100, 'The description is too long!') })
- Look at the issue code when rendering errors (see a list of codes)
fieldErrors(fields.description).map(issue => { if (issue.code === 'too_big') { return 'The description is too long!' } return issue.message })
- Register a custom ZodErrorMap
const customErrorMap: z.ZodErrorMap = (issue, ctx) => { if (issue.code === 'invalid_type') { if (issue.expected === 'string') { return { message: 'bad type!' } } } if (issue.code === 'custom') { return { message: `less-than-${(issue.params || {}).minimum}` } } return { message: ctx.defaultError } } z.setErrorMap(customErrorMap)
Use the setError
function to set or clear errors for a particular field, or the entire form.
setError(fields.image, { code: 'custom', message: 'Server failed to upload' })
// Clear all errors
setError(fields, undefined)
Importantly, native HTML input
, textarea
and select
all use strings to store their values. Because of this, undefined
or null
are not valid values for native fields, and the following schema defines a string that is not required*.
const schema = z.object({
notRequired: z.string()
})
* Actually, it is required unless you pass initialValues
Because the form value is initially an empty object, notRequired
may actually be set to undefined
initially, until something is typed into the input, even if it's deleted.
There are a few ways to mitigate this:
- Treat both
invalid_type
andtoo_small
errors as required. You can use a custom ZodErrorMap for this purpose.const customErrorMap: z.ZodErrorMap = (issue, ctx) => { if ( (issue.code === 'invalid_type' && issue.received === 'undefined') || (issue.code === 'too_small' && issue.minimum === 1) ) { return { message: 'This field is required' } } return { message: ctx.defaultError } } z.setErrorMap(customErrorMap)
- Pass
initialValues
to theuseForm
hook. This will allow you to provide an initial value of''
for that field, preventing it from ever beingundefined
.const { fields } = useForm({ schema, initialValues: { notRequired: '' } })
- Make the field
optional
in the schema. This will allow your field to actually beundefined
, which may be what you want anyway.const schema = z.object({ notRequired: z.string().optional() })
In order to make a field "required", you need to add min(1)
to ensure that it has at least 1 character:
const schema = z.object({
minOne: z.string().min(1)
})
You can use the coerce
functionality in Zod to handle number fields if you'd like your parsed value to be a number.
const schema = z.object({
age: z.coerce.number(),
})
const onSubmit = values => {
console.log(typeof values.age) // 'number'
}
<input {...fields.age.register()} type="number" />
Checkboxes will be detected and coerced into booleans automatically, so you don't need to do anything special.
const schema = z.object({
acceptedAgreement: z.boolean(),
})
const onSubmit = values => {
console.log(typeof values.acceptedAgreement) // 'boolean'
}
<input {...fields.acceptedAgreement.register()} type="checkbox" />
It's recommended to use an enum type to validate radio groups.
const schema = z.object({
favoriteColor: z.enum(['red', 'green', 'blue']),
})
const { fields } = useForm({ schema })
return <form>
<label>
<input {...fields.favoriteColor.register()} type="radio" value="red" />
<span>Red</span>
</label>
<label>
<input {...fields.favoriteColor.register()} type="radio" value="green" />
<span>Green</span>
</label>
<label>
<input {...fields.favoriteColor.register()} type="radio" value="blue" />
<span>Blue</span>
</label>
</form>
React Zoom Form supports nested object and array fields. You can access them on the fields object as you'd expect.
const schema = z.object({
address: z.object({
street: z.string().min(1),
country: z.string().min(1),
}),
tasks: z.array(
z.object({
name: z.string().min(1),
isCompleted: z.boolean(),
})
)
})
const { fields } = useForm({ schema })
// Register a native input for street
<input {...fields.address.street.register()} type="text" />
// Register a native input for task completion
<input {...fields.tasks[0].isCompleted.register()} type="checkbox" />
// Get errors for any fields in the address object
fieldErrors(fields.address)
A controlled
helper function is provided to make interacting with 3rd party field components or creating your own complex fields easy. See the API reference for controlled
.
Note that ControlledField<string[]>
is used to type the props of the MultiSelectField
component, which will only allow a field of that type to be passed in. This allows components to define the data shape they can handle, while still being useable outside of a form with a normal useState
hook.
import { useForm, controlled, ControlledField } from '@stevent-team/react-zoom-form'
import { z } from 'zod'
const schema = z.object({
colors: z.array(z.string()).min(2),
})
const Page = () => {
const { fields, handleSubmit } = useForm({ schema })
const onSubmit = values => {
console.log(values)
}
return <form onSubmit={handleSubmit(onSubmit)}>
<MultiSelectField
{...controlled(fields.colors)}
options={['green', 'purple', 'orange', 'lavender']}
/>
<button>Save changes</button>
</form>
}
const MultiSelectField = ({ value, onChange, errors, options }: ControlledField<string[]> & { options: string[] }) => <>
<div>
{options.map(option => <label key={option}>
<input
type="checkbox"
checked={value.includes(option)}
onChange={e => {
if (e.currentTarget.checked) {
onChange({ ...value, option })
} else {
onChange(value.filter(c => c !== option))
}
}}
/>
<span>{option}</span>
</label>)}
</div>
{errors.length > 0 && <span>{errors.map(e => e.message).join(', ')}</span>}
</>
Two helper functions are provided to get and set values directly. See API documentation for getValue and setValue.
const schema = z.object({
password: z.string().min(8),
})
const { fields } = useForm({ schema })
const password = getValue(fields.password)
useEffect(() => {
if (!loginResponse.ok) {
setValue(fields.password, '')
}
}, [loginResponse])
You can set up conditional fields using Zod's refine
method.
const schema = z.object({
joinNewsletter: z.boolean(),
email: z.string().email().optional(),
}).refine(values => {
if (values.joinNewsletter === false) return true
return values.email !== undefined && values.email !== ''
}, 'Email is required')
const { fields } = useForm({ schema })
const joinNewsletter = getValue(fields.joinNewsletter)
return <form>
<input {...fields.joinNewsletter.register()} type="checkbox" />
{joinNewsletter && <input {...fields.email.register()} type="email" />}
</form>
You can provide initialValues
to the useForm
hook if you'd like your fields to start with an initial value. This is also what determines the isDirty
state of your form. By default, initialValues
is an empty object.
At any time, you can reset your form data to initialValues
by calling reset
, which is provided by the useForm
hook.
Note that reset
behaves like setting initialValues
, and so will correctly calculate the state of isDirty
.
const { fields, reset, isDirty } = useForm({ schema })
useEffect(() => {
reset(apiData)
}, [apiData])
return <form>
<>...</>
<button disabled={!isDirty}>Save changes</button>
</form>
If you also need access to the ref
of an input you're using register()
on, you can pass it to the options of register like so:
const { fields } = useForm({ schema })
const myInputRef = useRef<HTMLInputElement>(null)
return <form>
<input {...fields.myInput.register({ ref: myInputRef })} />
<button
type="button"
onClick={() => myInputRef.current.focus()}
>Focus my input</button>
</form>
- If you're computing your schema inside the react component that calls
useForm
, be sure to memoize the schema so rerenders of the component do not recalculate the schema. This also goes forinitialValues
.
const { fields, handleSubmit, isDirty, reset } = useForm(options)
Option | Type | Description |
---|---|---|
schema |
AnyZodObject |
Your Zod schema used to validate the form. |
initialValues |
RecursivePartial<z.infer<Schema>> | undefined |
Optionally pass initial values for the fields. |
Property | Type | Description |
---|---|---|
fields |
FieldChain<Schema> |
Field chain for the form. Types are based off the provided Zod schema. |
handleSubmit |
(handler: SubmitHandler<Schema>) => React.FormEventHandler<HTMLFormElement> |
Higher-order function for handling form submission event. Takes a function that it will call with parsed values on submit. |
isDirty |
boolean |
Deeply compares the initialValues of a form with the current value to tell you if any of the fields have changed. |
reset |
(values?: RecursivePartial<z.TypeOf<Schema>>) => void |
Resets a form's value to initialValues , or data you pass in directly. |
The fields
object will match the shape of your Zod schema, and also provides a register
and name
function at each leaf node.
Property | Type | Description |
---|---|---|
register |
(options?: RegisterOptions) => ReturnType<RegisterFn> |
Takes options and returns onChange , name and ref props that you can pass to a native input , textarea or select element. |
name |
() => string |
Returns a unique name for this field. Useful for linking label elements. |
Takes a field from the fields
property of the useForm
hook.
const { name, schema, value, onChange, errors } = controlled(field)
Property | Type | Description |
---|---|---|
name |
string |
Unique name for this field. |
schema |
z.ZodType<T> |
The Zod schema for this field. Can be used to parse internally. |
value |
PartialObject<T> | undefined |
The value of this field. |
onChange |
(value: PartialObject<T> | undefined) => void |
Call to change the value of this field. |
errors |
z.ZodIssue[] |
An array of ZodIssues for this field. If there are no issues the array will be empty. |
Takes a field from the fields
property of the useForm
hook.
const errors = fieldErrors(field)
Property | Type | Description |
---|---|---|
errors |
z.ZodIssue[] |
An array of ZodIssues for this field. If there are no issues the array will be empty. |
Takes a field from the fields
property of the useForm
hook.
const value = getValue(field)
Property | Type | Description |
---|---|---|
value |
PartialObject<NonNullable<T>> | undefined |
The value of this field. |
setValue(field, newValue)
Argument | Type | Description |
---|---|---|
field |
Field |
A field from the fields property of the useForm hook. |
newValue |
PartialObject<NonNullable<T>> | undefined | ((currentValue: PartialObject<NonNullable<T>> | undefined) => PartialObject<NonNullable<T>> | undefined) |
Takes a value to set this field to, or a function from the current value to the desired value. |
Renders a span
.
<Errors field={field} max={undefined} issueMap={issue => issue.message} separator=", " />
Property | Type | Description |
---|---|---|
field |
Field |
A field from the fields property of the useForm hook. |
max |
number | undefined |
The maximum number of errors to show. |
issueMap |
((issue: ZodIssue) => string) | undefined |
Change the data displayed for an issue. |
separator |
string | undefined |
The separator used to join all the issues. |
You can install dependencies by running yarn
after cloning this repo, and yarn dev
to start the example.
This library uses changesets, if the changes you've made would constitute a version bump, run yarn changeset
and follow the prompts to document the changes you've made. Changesets are consumed on releases, and used to generate a changelog and bump version number.
React Zoom Form is created by Stevent and licensed under MIT
Car image created by Ewan Breakey and licensed under CC BY-NC-SA 3.0