Building robust forms in Next.js is thirsty work!
Not only must you validate forms on the server, you must validate them on the client as well.
On the client, to ensure the user has a smooth experience, fields should be revalidated when their value changes, but only if the field has been "touched" or the form previously-submitted.
If JavaScript is disabled, the form ought to regress gracefully. Dynamic form validation won't be possible, but errors should still render alongside their respective fields and preserve their values between requests to the server.
You want to do all this without writing a bunch of duplicate code and, in this case, without a form library like React Hook Form.
Here's how a senior developer would do it utilising Zod ⬇️
Zod allows you to define the shape of a valid for submission. Provided you do so in a separate file, you can reference the definition from either the server or a client component, eliminating the possibility of duplicate code.
import { z } from "zod"
export const signUpFormSchema = z.object({
email: z.string().email({ message: "Please enter a valid email." }).trim(),
password: z
.string()
.min(8, { message: "Be at least 8 characters long" })
.regex(/[a-zA-Z]/, { message: "Contain at least one letter." })
.regex(/[0-9]/, { message: "Contain at least one number." })
.regex(/[^a-zA-Z0-9]/, {
message: "Contain at least one special character."
})
.trim()
})
export type SignUpActionState = {
form?: {
email?: string
password?: string
}
errors?: {
email?: string[]
password?: string[]
}
}
To validate the form on the server, import and and validate against the schema when the sever action is submitted:
"use server"
import { redirect } from "next/navigation"
import { SignUpActionState, signUpFormSchema } from "./schema"
export async function signUpAction(
_prev: SignUpActionState,
formData: FormData
): Promise<SignUpActionState> {
const form = Object.fromEntries(formData)
const validationResult = signUpFormSchema.safeParse(form)
if (!validationResult.success) {
return {
form,
errors: validationResult.error.flatten().fieldErrors
}
}
redirect("/")
}
On the client, in a client component denoted with "use client"
, create your form:
ℹ️ Info |
---|
<ValidatedInput /> isn't defined yet - take a moment to understand the form first |
"use client"
import { useActionState, useState } from "react"
import { signUpAction } from "./action"
import { signUpFormSchema } from "./schema"
import { ValidatedInput } from "@/components/ui/validated-input"
export default function SignUpForm() {
const [wasSubmitted, setWasSubmitted] = useState(false)
const [state, action, isPending] = useActionState(signUpAction, {})
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
setWasSubmitted(true)
const formData = new FormData(event.currentTarget)
const data = Object.fromEntries(formData)
const validationResult = signUpFormSchema.safeParse(data)
if (!validationResult.success) {
event.preventDefault()
}
}
return (
<form onSubmit={handleSubmit} action={action} noValidate>
<div>
<label htmlFor="email">Email:</label>
<ValidatedInput
type="email"
name="email"
wasSubmitted={wasSubmitted}
fieldSchema={signUpFormSchema.shape["email"]}
defaultValue={state.form?.email}
errors={state.errors?.email}
/>
</div>
<div>
<label htmlFor="password">Password:</label>
<ValidatedInput
type="password"
name="password"
fieldSchema={signUpFormSchema.shape["password"]}
wasSubmitted={wasSubmitted}
defaultValue={state.form?.password}
errors={state.errors?.password}
/>
</div>
<div>
<button type="submit" disabled={isPending}>
Continue
</button>
</div>
</form>
)
}
When the form is submitted, onSubmit
validates the form before indirectly invoking the server action.
The form component above is not concerned about rendering errors - that is the responsibility of <ValidatedInput />
:
<ValidatedInput
type="password"
name="password"
fieldSchema={signUpFormSchema.shape["password"]}
wasSubmitted={wasSubmitted}
defaultValue={state.form?.password}
errors={state.errors?.password}
/>
Note how we extract the fieldSchema
from signUpFormSchema
using signUpFormSchema.shape
. By passing the field schema in this way, <ValidatedInput />
remains flexible and reusable across your different forms.
Here's <ValidatedInput />
in full:
import { useState, useCallback } from "react"
import { Input } from "./input"
const ValidatedInput = ({
name,
wasSubmitted,
errors,
fieldSchema,
...props
}) => {
const [value, setValue] = useState("")
const [touched, setTouched] = useState(false)
const getErrors = useCallback(() => {
const validationResult = fieldSchema.safeParse(value)
return validationResult.success
? []
: validationResult.error.flatten().formErrors
}, [fieldSchema, value])
const fieldErrors = errors || getErrors()
const shouldRenderErrors = errors || wasSubmitted || touched
const handleBlur = () => setTouched(true)
const handleChange = (e) => setValue(e.currentTarget.value)
return (
<>
<Input
id={name}
name={name}
onBlur={handleBlur}
onChange={handleChange}
className={fieldErrors.length > 0 ? "border-red-500" : ""}
{...props}
/>
{shouldRenderErrors && (
<span className="text-sm text-red-500">{fieldErrors}</span>
)}
</>
)
}
export { ValidatedInput }
It's based on Kent's FastInput
.
Top comments (0)