Validação do formulário React com React Hook Form e Zod


A lógica de validação duplicada é uma das fontes mais persistentes de bugs em aplicativos React. Uma regra do lado do cliente muda, a cópia do lado do servidor permanece a mesma e os dados inválidos passam pela lacuna. A validação do formulário React que depende de um único esquema Zod, compartilhado entre React Hook Form no cliente e uma ação do servidor Next.js no servidor, elimina totalmente esse desvio. Este tutorial percorre a construção desse padrão do zero: um formulário de contato com validação do lado do cliente via zodResolverrevalidação do lado do servidor com safeParserenderização de erros acessível seguindo práticas de autoria ARIA e um conjunto de testes Vitest que bloqueia a correção do esquema.

O resultado é um formulário de contato do Next.js App Router onde cada regra de validação reside em um arquivo. Quando o servidor rejeita um envio, os erros em nível de campo são mapeados de volta para entradas individuais usando setErrore os leitores de tela os anunciam por meio aria-describedby associações.

Pré-requisitos: você deve conhecer React, TypeScript e Next.js App Router. Você também precisará do Node 20.9 ou posterior, que é o mínimo para as versões atuais do Next.js. O código abaixo foi verificado com TypeScript 5.9.3 e 7.0.2 e os testes foram executados com Vitest 5.0.5, React Hook Form 7.90.0, @hookform/resolvers 5.9.1 e Zod 4.6.7.

Índice

Configuração e dependências do projeto

Estruturar o aplicativo Next.js

Comece com o padrão create-next-app andaimes. Os sinalizadores abaixo suprimem todos os prompts interativos e garantem que a estrutura de diretórios resultante corresponda aos caminhos de arquivo usados ​​neste tutorial:

npx create-next-app@latest contact-form-demo --typescript --app --no-eslint --no-tailwind --no-src-dir --import-alias '@/*'
cd contact-form-demo

Cada bandeira é importante: --no-src-dir coloca o app/ diretório na raiz do projeto (em vez de dentro src/), e --import-alias '@/*' configura o @/ alias do caminho em tsconfig.json para que importações como @/lib/schemas/contact resolver corretamente.

Instale pacotes de validação e teste

A camada de formulário requer três pacotes: react-hook-form para a gestão do estado, @hookform/resolvers para conectar bibliotecas de esquema externas e zod para definição de esquema. O teste depende vitest.

npm install react-hook-form @hookform/resolvers zod
npm install -D vitest

Adicionar @testing-library/react se você planeja escrever testes em nível de componente além dos testes de esquema mostrados neste tutorial.

Após a instalação, a parte relevante do package.json deve ser semelhante a:

{
  "dependencies": {
    "next": "^16.4.2",
    "react": "^19.3.1",
    "react-dom": "^19.3.1",
    "react-hook-form": "^7.90.0",
    "@hookform/resolvers": "^5.9.1",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "vitest": "^5.0.5",
    "typescript": "^5.9.3"
  }
}

O devDependencies o bloco mostrado está resumido. Seu arquivo real também conterá @types/react, @types/react-dom, @types/nodee pacotes relacionados ao ESLint instalados por create-next-app. Eles são omitidos aqui por questões de brevidade.

Definindo o esquema Zod compartilhado

Crie o arquivo de esquema

O esquema reside em seu próprio arquivo para que tanto o componente cliente quanto a ação do servidor possam importá-lo sem extrair dependências desnecessárias.


import { z } from "zod";

export const contactSchema = z.object({
  name: z
    .string()
    .min(2, { message: "Name must be at least 2 characters." }),
  email: z.email({ message: "Please enter a valid email address." }),
  message: z
    .string()
    .min(10, { message: "Message must be at least 10 characters." })
    .max(500, { message: "Message must not exceed 500 characters." }),
});

export type ContactFormData = z.infer<typeof contactSchema>;

Cada campo carrega uma mensagem de erro personalizada. Este tutorial usa Zod 4, onde z.email() substitui o obsoleto z.string().email(); no Zod 3 mantenha a forma mais antiga. O ContactFormData tipo é inferido diretamente do esquema com z.inferportanto, o tipo TypeScript e as regras de validação de tempo de execução nunca podem divergir.

Por que este arquivo é a única fonte da verdade

Tanto o componente cliente quanto a importação da Ação do Servidor contactSchema deste módulo. No cliente, zodResolver envolve o esquema para que o React Hook Form possa avaliá-lo durante seu ciclo de validação. No servidor, safeParse() executa as mesmas regras em relação à entrada bruta e retorna erros estruturados sem lançar. Você cria o esquema uma vez, mas ele é executado em dois tempos de execução. Qualquer alteração em uma regra de validação se propaga imediatamente para ambos os lados.

Você cria o esquema uma vez, mas ele é executado em dois tempos de execução. Qualquer alteração em uma regra de validação se propaga imediatamente para ambos os lados.

Construindo o componente Form com React Hook Form

O componente completo é mostrado abaixo como um único arquivo. Ele combina a configuração do gancho, o onSubmit manipulador e o retorno JSX na ordem em que aparecem na fonte.


"use client";

import React from "react";
import { useForm } from "react-hook-form";
import type { Path } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormData } from "@/lib/schemas/contact";
import { submitContactForm } from "@/app/actions/contact";
import { applyServerErrors } from "@/lib/utils/applyServerErrors";

const VALID_FIELDS = ("name", "email", "message") as const satisfies ReadonlyArray<
  Path<ContactFormData>
>;

export default function ContactForm() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    mode: "onTouched",
  });

  const (successMessage, setSuccessMessage) = React.useState<string | null>(null);

  async function onSubmit(data: ContactFormData) {
    setSuccessMessage(null);
    try {
      const result = await submitContactForm(data);

      if (!result.success && result.fieldErrors) {
        applyServerErrors(setError, result.fieldErrors, VALID_FIELDS);
        return;
      }

      if (result.success) {
        reset();
        setSuccessMessage("Message sent successfully!");
      }
    } catch (err) {
      console.error("ContactForm: unexpected submission error", err);
      setError("root", {
        type: "server",
        message: "A network error occurred. Please try again.",
      });
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate aria-busy={isSubmitting}>
      {successMessage && (
        <div role="status">
          {successMessage}
        </div>
      )}

      {errors.root && (
        <div role="alert">
          {errors.root.message}
        </div>
      )}

      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          type="text"
          {...register("name")}
          aria-invalid={!!errors.name}
          aria-describedby="name-error"
        />
        <span id="name-error" role="alert">
          {errors.name?.message ?? ""}
        </span>
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register("email")}
          aria-invalid={!!errors.email}
          aria-describedby="email-error"
        />
        <span id="email-error" role="alert">
          {errors.email?.message ?? ""}
        </span>
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          rows={5}
          {...register("message")}
          aria-invalid={!!errors.message}
          aria-describedby="message-error"
        />
        <span id="message-error" role="alert">
          {errors.message?.message ?? ""}
        </span>
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending…" : "Send Message"}
      </button>
    </form>
  );
}

Fiação useForm para zodResolver

O useForm gancho aceita um resolver opção que delega a validação ao Zod. Contexto mode para 'onTouched' significa que os campos são validados quando desfocados e revalidados em alterações subsequentes, fornecendo aos usuários feedback imediato sem acionar erros antes de interagirem com um campo.

O register A função conecta entradas ao estado interno do React Hook Form. errors fornece objetos de erro por campo e isSubmitting rastreia se um envio assíncrono está em andamento.

Renderizando entradas com mensagens de erro acessíveis

Cada entrada recebe aria-invalid quando seu erro correspondente existe e aria-describedby apontando para um que contém a mensagem de erro. O aria-describedby atributo está sempre presente (apontando para um renderizado consistentemente ) para que os leitores de tela mantenham a associação mesmo quando os erros forem eliminados. Isso segue as práticas de criação ARIA para mensagens de erro in-line: aria-describedby liga o erro à sua entrada, e aria-invalid sinaliza o estado do erro, de modo que os leitores de tela anunciam o erro quando o usuário focaliza o campo.

O noValidate atributo no element turns off built-in browser validation so Zod’s rules run instead. The submit button disables itself during submission to prevent duplicate requests. The aria-busy O atributo no formulário comunica o estado de carregamento à tecnologia assistiva.

Lidando com envio e chamando a ação do servidor

Quando a validação do lado do cliente do React Hook Form for aprovada, onSubmit incêndios. Ele chama a ação do servidor, inspeciona a resposta em busca de erros de campo e mapeia quaisquer erros retornados pelo servidor de volta para entradas individuais usando o método applyServerErrors auxiliar (definido na seção “Mapeando erros do servidor” abaixo). UM try/catch O bloco envolve o manipulador para que falhas de rede ou falhas inesperadas de ação do servidor apareçam como um erro de formulário no nível raiz, em vez de deixar o formulário preso em um estado de envio.

O type: 'server' corda usada dentro applyServerErrors é uma convenção, não um requisito do React Hook Form. Ele torna possível distinguir erros originados no servidor daqueles originados no cliente ao registrar, estilizar ou rotear eventos analíticos.

Criando a ação do servidor com revalidação

Defina a ação

A Ação do Servidor importa o mesmo contactSchema e corre safeParse contra os dados recebidos. Se a análise falhar, os erros do campo nivelado retornarão ao cliente. Se tiver sucesso, a lógica de negócios é executada dentro de um try/catch bloco para garantir que erros inesperados retornem como respostas estruturadas, em vez de travar a ação. Em uma aplicação real, o branch de sucesso executaria uma operação concreta como inserir uma linha em um contacts tabela via Prisma ou enviando um email transacional.


"use server";

import { z } from "zod";
import { contactSchema } from "@/lib/schemas/contact";

type ActionResult =
  | { success: true; fieldErrors?: never }
  | { success: false; fieldErrors: Record<string, string()> };

export async function submitContactForm(
  rawData: unknown
): Promise<ActionResult> {
  const result = contactSchema.safeParse(rawData);

  if (!result.success) {
    const raw = z.flattenError(result.error).fieldErrors;
    
    const fieldErrors: Record<string, string()> = Object.fromEntries(
      Object.entries(raw).filter(
        (entry): entry is (string, string()) =>
          Array.isArray(entry(1)) && entry(1).length > 0
      )
    );
    return { success: false, fieldErrors };
  }

  try {
    
    
    return { success: true };
  } catch (err) {
    console.error("submitContactForm: business logic error", err);
    return {
      success: false,
      fieldErrors: {
        root: ("An unexpected error occurred. Please try again later."),
      },
    };
  }
}

O parâmetro é digitado unknown deliberadamente. Digitando ContactFormData permitiria que o TypeScript aceitasse os dados sem nova análise, anulando a revalidação do lado do servidor.

O z.flattenError() ajudante vira um ZodError em um apartamento { formErrors: string(), fieldErrors: { name: string(), email: string() } } forma, que mapeia diretamente para o setError loop no cliente. (Zod 3 e 4 também têm um error.flatten() método, obsoleto no Zod 4.) Zod digita cada valor de campo como opcional por meio de um registro parcial, de modo que a etapa de filtragem acima restringe undefined entradas e o retorno fieldErrors objeto é um cofre Record.

Por que a revalidação do servidor é importante

A validação do lado do cliente é executada no navegador. Um usuário pode ignorá-lo totalmente desabilitando o JavaScript, manipulando os dados do formulário por meio do DevTools ou enviando solicitações diretamente via cURL ou um bot automatizado. A revalidação do servidor usando o mesmo esquema garante que regras idênticas sejam aplicadas independentemente de como a solicitação chega.

A revalidação do servidor usando o mesmo esquema garante que regras idênticas sejam aplicadas independentemente de como a solicitação chega.

Implemente limitação de taxa e verificação de token CSRF juntamente com validação de esquema. Estas medidas complementares abordam diferentes vetores de ameaças, embora estejam fora do escopo deste tutorial.

Mapeando erros do servidor de volta às entradas com setError

O applyServerErrors ajudante (usado no onSubmit manipulador acima) pega cada chave do servidor fieldErrors objeto e o injeta no estado de erro do React Hook Form. Como o erro se estende na leitura JSX de formState.errors.fieldNameo correto re-renderiza automaticamente e aria-invalid alterna para true na entrada correspondente. O contrato de erro acessível permanece intacto sem adicionar consultas DOM manuais ou ouvintes de eventos.

Para erros inesperados que não são mapeados para um campo específico, o React Hook Form suporta erros no nível raiz via setError('root.serverError', { message: '...' }) (requer react-hook-form >=7.34.0; como alternativa, use setError('root', ...) em versões anteriores). Isso é útil para lidar com casos como falhas de rede ou erros internos do servidor em que nenhum campo individual é o culpado.

Para manter esse padrão DRY em vários formulários, a lógica de mapeamento reside em um auxiliar reutilizável. O auxiliar aceita uma lista explícita de caminhos de campo válidos e roteia quaisquer chaves não reconhecidas para um erro de nível raiz, evitando que chaves fornecidas pelo servidor corrompam silenciosamente o estado do formulário:


import { UseFormSetError, FieldValues, Path } from "react-hook-form";

const SERVER_ERROR_TYPE = "server" as const;

export function applyServerErrors<T extends FieldValues>(
  setError: UseFormSetError<T>,
  fieldErrors: Record<string, string()>,
  validFields: ReadonlyArray<Path<T>>
): void {
  const validSet = new Set<string>(validFields);
  for (const (field, messages) of Object.entries(fieldErrors)) {
    if (!messages || messages.length === 0) continue;
    if (validSet.has(field)) {
      setError(field as Path<T>, {
        type: SERVER_ERROR_TYPE,
        message: messages(0),
      });
    } else {
      
      setError("root" as Path<T>, {
        type: SERVER_ERROR_TYPE,
        message: messages(0),
      });
    }
  }
}

Este auxiliar é genérico em relação ao tipo de valores de campo do formulário, portanto funciona com qualquer esquema Zod e qualquer formulário que use React Hook Form. O validFields parâmetro garante que apenas caminhos de campos de formulário conhecidos sejam passados ​​para setErrorenquanto quaisquer chaves inesperadas da resposta do servidor são roteadas com segurança para um erro no nível raiz. O ContactForm O componente o importa diretamente, substituindo o loop embutido mostrado em rascunhos anteriores.

Testando o esquema com Vitest

Configuração do Vitest

Criar vitest.config.ts na raiz do projeto. Isto configura o @/ alias do caminho para que o Vitest possa resolver as mesmas importações usadas no código da aplicação:


import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./"),
    },
  },
  test: {
    environment: "node",
  },
});

Testes unitários para dados válidos e inválidos

Como o esquema é um módulo independente sem dependências de UI, ele pode ser testado isoladamente com testes de unidade rápidos. Cada teste chama safeParse e afirmações sobre o resultado estruturado.


import { z } from "zod";
import { describe, it, expect } from "vitest";
import { contactSchema } from "@/lib/schemas/contact";

describe("contactSchema", () => {
  it("accepts a valid payload", () => {
    const result = contactSchema.safeParse({
      name: "Jane Doe",
      email: "jane@example.com",
      message: "Hello, this is a valid message.",
    });
    expect(result.success).toBe(true);
  });

  it("rejects a name shorter than 2 characters", () => {
    const result = contactSchema.safeParse({
      name: "J",
      email: "jane@example.com",
      message: "Hello, this is a valid message.",
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(z.flattenError(result.error).fieldErrors.name).toContain(
        "Name must be at least 2 characters."
      );
    }
  });

  it("rejects an invalid email address", () => {
    const result = contactSchema.safeParse({
      name: "Jane Doe",
      email: "not-an-email",
      message: "Hello, this is a valid message.",
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(z.flattenError(result.error).fieldErrors.email).toContain(
        "Please enter a valid email address."
      );
    }
  });

  it("rejects a message shorter than 10 characters", () => {
    const result = contactSchema.safeParse({
      name: "Jane Doe",
      email: "jane@example.com",
      message: "Short",
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(z.flattenError(result.error).fieldErrors.message).toContain(
        "Message must be at least 10 characters."
      );
    }
  });

  it("rejects a message exceeding 500 characters", () => {
    const result = contactSchema.safeParse({
      name: "Jane Doe",
      email: "jane@example.com",
      message: "A".repeat(501),
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(z.flattenError(result.error).fieldErrors.message).toContain(
        "Message must not exceed 500 characters."
      );
    }
  });

  it("rejects a completely empty payload", () => {
    const result = contactSchema.safeParse({});
    expect(result.success).toBe(false);
  });
});

Esses testes afirmam mensagens de erro exatas, que capturam regressões na cópia voltada para o usuário, bem como na lógica de validação.

Executando a suíte

Execute o pacote completo com npx vitest run. Para feedback contínuo durante o desenvolvimento, npx vitest inicia o modo de observação. Adicionar qualquer um dos comandos a um pipeline de CI garante que as alterações de esquema sejam verificadas em cada push.

Lista de verificação de práticas recomendadas de validação de formulário React

  1. Esquema único, dois tempos de execução. Defina a validação uma vez no Zod, execute-a no cliente e no servidor.
  2. Usar zodResolver para feedback instantâneo do cliente. Ele conecta o React Hook Form ao esquema sem funções de validação manual.
  3. Revalidar com safeParse em cada ação do servidor. A validação do lado do cliente sempre pode ser ignorada.
  4. Mapeie os erros do servidor de volta aos campos com setError. Isso mantém o ciclo de feedback fechado entre o servidor e a UI.
  5. Aplicar aria-invalid e aria-describedby em cada entrada. Os leitores de tela anunciam erros de contexto seguindo as práticas de autoria ARIA.
  6. Definir mode para 'onTouched' para evitar erros prematuros. 'onBlur' valida apenas no desfoque e ignora a revalidação nas alterações subsequentes; 'onTouched' faz as duas coisas e geralmente é preferível.
  7. Teste de unidade o esquema, não apenas a IU. Os testes de esquema são executados em milissegundos e detectam regressões de regras antecipadamente.
  8. Digite os dados do formulário do esquema com z.infer. Nunca duplique manualmente os tipos que o esquema já define.
  9. Se o seu formulário lida com uploads de arquivos, valide o tipo e tamanho MIME no cliente com um refinamento Zod personalizado e inspecione os dados brutos FormData entradas no servidor desde que os arquivos carregados chegam no servidor FormData em vez de em um objeto digitado.
  10. Desative o botão enviar durante isSubmitting para evitar envios duplicados durante a latência da rede. Lidar com erros do servidor no nível raiz por meio de setError('root.serverError', ...) para falhas que não pertencem a um campo específico.

Para onde ir a partir daqui

Um único esquema Zod, compartilhado entre React Hook Form’s zodResolver e uma ação do servidor Next.js, elimina o desvio de validação. O setError o mapeamento fecha o ciclo de feedback entre o servidor e o cliente. Os atributos ARIA garantem que os estados de erro sejam perceptíveis pela tecnologia assistiva. Vitest bloqueia a correção do esquema independentemente da IU.

Um único esquema Zod, compartilhado entre React Hook Form’s zodResolver e uma ação do servidor Next.js, elimina o desvio de validação.

A partir daqui, adicione validação de upload de arquivo no cliente com z.instanceof(File). Observe que a validação de arquivos do lado do servidor requer inspeção FormData entradas diretamente, já que os arquivos carregados chegam no servidor FormData em vez de em um objeto digitado. Você também pode integrar useFormStatus do React DOM para indicadores de envio de streaming; este gancho deve residir em um componente filho do element, not in the component that renders the form itself. For multi-step forms, split a larger schema across wizard steps using z.pick() ou z.omit() portanto, cada etapa valida apenas seu próprio subconjunto.

Para ler mais sobre React Hook Form e Zod, consulte os guias relevantes no SitePoint.



Source link