Build your own form
Learn how to build your own form with React and usng Standard Schema to validate the form.
- React
- Form
- Standard Schema
Introduction
In this tutorial, we will learn how to build a form with React and validate it using Standard Schema.
Installation
First, let's create a new Next.js project.
npx shadcn@latest init
pnpm dlx shadcn@latest init
npx shadcn@latest init
bunx --bun shadcn@latest init
Second, install the required packages.
npm install @standard-schema/spec @radix-ui/react-slot
pnpm add @standard-schema/spec @radix-ui/react-slot
yarn add @standard-schema/spec @radix-ui/react-slot
bun add @standard-schema/spec @radix-ui/react-slot
npm install arktype # or zod, valibot
pnpm add arktype # or zod, valibot
yarn add arktype # or zod, valibot
bun add arktype # or zod, valibot
Create a form
Create a new file components/ui/form.tsx
and add the following code.
- Create the
useForm
hook to manage the form state.
const useForm = <TSchema extends StandardSchemaV1, TData = unknown>({
schema,
defaultValues,
submitFn,
onSuccess,
onError,
}: {
schema: TSchema
defaultValues: StandardSchemaV1.InferInput<TSchema>
submitFn: (
values: StandardSchemaV1.InferInput<TSchema>,
) => Promise<TData> | TData
onSuccess?: (data: TData) => Promise<void> | void
onError?: (error: string) => Promise<void> | void
}) => {
const [values, setValues] = React.useState(defaultValues)
const [isPending, startTransition] = React.useTransition()
const [errors, setErrors] = React.useState<{
message?: string
fieldErrors?: Record<keyof StandardSchemaV1.InferInput<TSchema>, string>
}>({})
const handleSubmit = React.useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
startTransition(async () => {
e.preventDefault()
e.stopPropagation()
const parsed = await schema['~standard'].validate(values)
if (parsed.issues) {
setErrors({
message: 'Validation error',
fieldErrors: parsed.issues.reduce<Record<string, string>>(
(acc, issue) => ({
...acc,
[issue.path as never]: issue.message,
}),
{},
) as Record<keyof StandardSchemaV1.InferInput<TSchema>, string>,
})
if (onError) void onError('Validation error')
return
}
try {
const data = await submitFn(parsed.value)
if (onSuccess) void onSuccess(data)
setErrors({})
} catch (error) {
if (error instanceof Error) {
setErrors({ message: error.message })
if (onError) void onError(error.message)
} else {
setErrors({ message: 'Unknown error' })
if (onError) void onError('Unknown error')
}
}
})
},
[onError, onSuccess, schema, submitFn, values],
)
const handleChange = (key: string, value: unknown) => {
setValues((prev) => ({
...(prev as Record<string, unknown>),
[key]: value,
}))
}
const handleBlur = React.useCallback(
async (event: React.FocusEvent<HTMLInputElement>) => {
const parsed = await schema['~standard'].validate({
...(values as Record<string, unknown>),
[event.target.name]: event.target.value,
})
if (parsed.issues) {
parsed.issues.forEach((issue) => {
setErrors((prev) => ({
...prev,
fieldErrors: {
...(prev.fieldErrors as unknown as Record<
keyof StandardSchemaV1.InferInput<TSchema>,
string
>),
[issue.path as never]: issue.message,
},
}))
})
} else {
setErrors((prev) => ({
...prev,
fieldErrors: {
...(prev.fieldErrors as unknown as Record<
keyof StandardSchemaV1.InferInput<TSchema>,
string
>),
[event.target.name]: undefined,
},
}))
}
},
[schema, values],
)
const reset = React.useCallback(() => {
setValues(defaultValues)
setErrors({})
}, [defaultValues])
return {
handleSubmit,
handleChange,
handleBlur,
reset,
isPending,
values,
errors,
}
}
In this hook, we use the useTransition
hook to handle the form submission. We validate the form data using the Standard Schema and show the error messages if there are any.
- Create the
Form
component to render the form.
type FormContextValue<T extends StandardSchemaV1> = ReturnType<
typeof useForm<T>
>
const FormContext = React.createContext<FormContextValue<StandardSchemaV1>>(
{} as FormContextValue<StandardSchemaV1>,
)
function Form<T extends StandardSchemaV1>({
className,
form,
...props
}: React.ComponentProps<'form'> & { form: FormContextValue<T> }) {
return (
<FormContext.Provider value={form}>
<form
data-slot="form"
className={cn('grid gap-4', className)}
onSubmit={form.handleSubmit}
{...props}
/>
</FormContext.Provider>
)
}
The Form
component uses the FormContext
to provide the form state to its children. It also handles the form submission and validation.
- Create the
FormField
component to render the form fields.
interface FormFieldContextValue {
name: string
value?: string
error?: string
isPending?: boolean
formItemId?: string
formDescriptionId?: string
formMessageId?: string
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
function FormField({
name,
render,
}: {
name: string
render: (props: {
name: string
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onBlur: (e: React.FocusEvent<HTMLInputElement>) => Promise<void>
}) => React.ReactNode
}) {
const form = React.use(FormContext)
return (
<FormFieldContext.Provider value={{ name }}>
{render({
name,
value: (form.values as never)[name],
onChange: React.useCallback(
(
event:
| React.ChangeEvent<HTMLInputElement>
| string
| number
| boolean,
) => {
if (event && typeof event === 'object') {
let newValue: unknown = event.target.value
if (event.target.type === 'number')
newValue = event.target.valueAsNumber
else if (event.target.type === 'checkbox')
newValue = event.target.checked
else if (event.target.type === 'date')
newValue = event.target.valueAsDate
form.handleChange(name, newValue)
} else {
form.handleChange(name, event)
}
},
[form, name],
),
onBlur: form.handleBlur,
})}
</FormFieldContext.Provider>
)
}
The FormField
component uses the FormFieldContext
to provide the form field state to its children. It also handles the form field changes and validation.
- Create the
FormItem
component to render the form item.
interface FormItemContextValue {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<'fieldset'>) {
const { isPending } = React.use(FormContext)
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<fieldset
data-slot="form-item"
data-disabled={isPending}
className={cn('group grid gap-2', className)}
disabled={isPending}
{...props}
/>
</FormItemContext.Provider>
)
}
The FormItem
component uses the FormItemContext
to provide the form item state to its children. It also handles the form item changes and validation.
- Create the
useFormField
hook to manage the form field state.
const useFormField = () => {
const formContext = React.use(FormContext)
const fieldContext = React.use(FormFieldContext)
const itemContext = React.use(FormItemContext)
return {
id: itemContext.id,
name: fieldContext.name,
value: (formContext.values as never)[fieldContext.name],
error: formContext.errors.fieldErrors?.[fieldContext.name as never],
isPending: formContext.isPending,
formItemId: `${itemContext.id}-form-item`,
formDescriptionId: `${itemContext.id}-form-item-description`,
formMessageId: `${itemContext.id}-form-item-message`,
}
}
The useFormField
hook provides the form field state to its children. It also handles the form field changes and validation.
- Finally, create the rest of the form components to render the form fields.
function FormLabel({ className, ...props }: React.ComponentProps<'label'>) {
const { formItemId, error } = useFormField()
return (
<label
data-slot="form-label"
data-error={!!error}
htmlFor={formItemId}
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
'data-[error=true]:text-destructive',
className,
)}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, isPending, formItemId, formDescriptionId, formMessageId } =
useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
aria-disabled={isPending}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { formMessageId, error } = useFormField()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const body = error ? String(error) : props.children
if (!body) return null
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
The FormLabel
, FormControl
, FormDescription
, and FormMessage
components render the form fields and handle the form field changes and validation.
Usage
Now, you can use the form components to build your own form.
- Create the form schema. In this example, we use the
arktype
package to create the form schema.
import { type } from 'arktype'
const loginSchema = type({
email: type('string.email'),
password: type(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
).describe('valid password'),
})
- Create the form variables.
const form = useForm({
schema: loginSchema,
defaultValues: { email: '', password: '' },
submitFn: async (values) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return values
},
onError: (e) => {
toast.error(e)
},
onSuccess: (data) => {
toast.success('Logged in successfully', {
description: <pre>{JSON.stringify(data, null, 2)}</pre>,
})
},
})
- Create the form fields.
'use client'
const LoginForm: React.FC = () => {
const form = useForm({ ... })
return (
<Form form={form}>
<FormField
name="email"
render={(props) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl {...props}>
<Input type="email" placeholder="yuki@gmail.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
render={(props) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<a
href="#"
className="text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<FormControl {...props}>
<Input type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button disabled={form.isPending} type="submit">
Login
</Button>
</Form>
)
}
Conclusion
In this tutorial, we learned how to build a form with React and validate it using Standard Schema. You can use the form components to build your own form and handle the form submission and validation.
I hope you enjoyed this tutorial. If you have any questions or feedback, feel free to leave a comment below.
Resources
- Standard Schema
- Documentation
- You can install this form using
shadcn
CLI
npx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
pnpm dlx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
npx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
bunx --bun add https://yuki-ui.vercel.app/r/form.json
How to build an authentication system from scratch
Learn how to build an authentication system from scratch using Arctic for OAuth.
ElysiaJS Integration in Next.js
Discover the benefits of using ElysiaJS as your backend for Next.js applications. Explore various integration methods and typesafe api calls.