Validate data in both Server Actions and Client in NextJs using Zod and React-Hook-Forms

Server Actions, introduced in Next.js version 14, are a convenient way to create functions that execute exclusively on the server side offering several advantages such as security, performance etc. Building robust applications often requires data validation on both the client side (user-interaction) and server-side (backend processing). Validations on the client side give instant feedback to users filling a form, creating a rich user experience, and as they say "never trust the client", it's also as important as validating the client as it's as validating in the backend.

There are several great tools for handling forms. Zod for the creation of structured definitions of the expected data format, including data types, constraints, and validation rules. React Hook Form serves as a powerful library for managing and validating form states in React applications providing an enhanced user experience.

Submit Component

Server actions can be invoked using the action attribute in an <form> element, useEffect, third-party libraries, and other form elements like <button>.

"use client"

import { Loader2 } from "lucide-react"
import { useFormStatus } from "react-dom"

import { Button, ButtonProps } from "@/components/ui/button"

type Props = ButtonProps & {
  pendingText?: string
  text: string
}

export function SubmitButton({ text, pendingText, ...props }: Props) {
  const { pending, action } = useFormStatus()

  const isPending = pending && action === props.formAction

  return (
    <Button {...props} disabled={isPending}>
      {isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
      {isPending ? pendingText : text}
    </Button>
  )
}

Instead of invoking forms with the form action attribute, we can create a simple button component using the React useFormStatus hook to show a pending state while the form is being submitted.

Server Action and Validation

With zod, we can create the structure of what our data should look like before we can have a valid form. This a simple schema for a to-do creation form.

import { z } from "zod"

export const todoSchema = z.object({
  title: z.string().min(5),
  description: z.string().min(5),
})

Server Components can use the inline function level or module level "use server" directive. To inline a Server Action, add "use server" to the top of the function body. When forms are validated on the server and there are errors, we can return serialized objects from our server actions and use React useFormState to show messages to our user. This changes the signature of our action to receive a new signature to receive a new prevState or initialState parameter as its first argument

"use server"

import { FormState } from "@/types"
import { todoSchema } from "@/utils/schema"

export async function createTodo(prevState: any, formData: FormData) {
  const result = todoSchema.safeParse(Object.fromEntries(formData.entries()))
  if (!result.success) {
    let errorMsgs: string[] = []

    result.error.issues.forEach((issue) => {
      errorMsg.push(issue.path[0] + ": " + issue.message)
    })

    return {
      errors: errorMsgs,
      message: "Error: Please Check Your Input!",
      type: "ValidationError",
    } as FormState
  }
/*
...*/
}

Client-Side Validation

For validation to hit on the client side before submitting the form, we will have the react-hook-form handle form state before submitting. Instead of invoking server action with the action attribute of the form, we create a function that handles the validation, triggers validation errors and shows the errors on the form, before submitting.

We will pass the action to the useFormState hook, which changes its signature of the action like we have declared earlier to receive a new prevState or initialState parameter as its first argument,

"use client"

import { FormState } from "@/types"
import { todoSchema } from "@/utils/schema"
import { zodResolver } from "@hookform/resolvers/zod"
import { useFormState } from "react-dom"
import { useForm } from "react-hook-form"
import { z } from "zod"

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { createTodo } from "@/app/actions"

import { SubmitButton } from "./SubmitButton"

const initialState: FormState = {
  message: "",
}

export default function Todo() {
  const [state, formAction] = useFormState(createTodo, initialState)

  const form = useForm<z.infer<typeof todoSchema>>({
    resolver: zodResolver(todoSchema),
    mode: "onChange",
    resetOptions: {
      keepValues: false,
    },
    defaultValues: {
      title: "",
      description: "",
    },
  })

  useEffect(() => {
   //handle form state
  }, [state])

  function clientAction(formData: FormData) {
    const result = todoSchema.safeParse(Object.fromEntries(formData.entries()))
    if (!result.success) {
      form.trigger()
    } else {
      formAction(formData)
    }
  }
  return (
    <Form {...form}>
      <form>
        <Card>
          <CardHeader>
            <CardTitle>Create Todo</CardTitle>
            <CardDescription>Form for creating a simple Todo</CardDescription>
          </CardHeader>
          <CardContent className="space-y-4">
            <FormField
              control={form.control}
              name="title"
              render={({ field }) => (
                <FormItem>
                  <div className="flex items-center">
                    <FormLabel>Title</FormLabel>
                  </div>
                  <FormControl>
                    <Input
                      type="text"
                      placeholder="Title"
                      {...field}
                      required
                    />
                  </FormControl>
                  <FormMessage className="text-xs" />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="description"
              render={({ field }) => (
                <FormItem>
                  <div className="flex items-center">
                    <FormLabel>Description</FormLabel>
                  </div>
                  <FormControl>
                    <Input
                      type="text"
                      placeholder="Description"
                      {...field}
                      required
                    />
                  </FormControl>
                  <FormMessage className="text-xs" />
                </FormItem>
              )}
            />
          </CardContent>
          <CardFooter>
            <SubmitButton
              className="ml-auto"
              formAction={clientAction}
              text="Create Todo"
              pendingText="Creating Todo..."
            />
          </CardFooter>
        </Card>
      </form>
    </Form>
  )
}

When the form is submitted the clientAction function tries to validate the form, if there are validation errors, the trigger function of the form object is called to populate the form using the FormMessage component. And if there are any errors server-side the useFormState sends the serialised object back into state, this way making use same schema to validate both the frontend and the backend.