ko-system/forms-validation

Formulários acessíveis com react-hook-form, zod e shadcn/ui Form, abrangendo erros inline, estados de pendente e desabilitado, feedback de sucesso e validação no servidor com esquemas compartilhados.

O que é forms-validation?

Este skill fornece um padrão robusto e acessível para criar formulários em React usando react-hook-form, zod e shadcn/ui. Ele define um único esquema zod como fonte única da verdade para tipos TypeScript, validação no cliente e validação no servidor. As regras incluem: schema primeiro, validação no servidor reutilizando o mesmo schema, campos com label visível e erro inline, modo de validação `onTouched`, botão de envio com estado de pendente, feedback de sucesso ou falha, e uso correto de atributos HTML como `type`, `inputMode` e `autoComplete`. O skill inclui um formulário de contato canônico com exemplos de código para schema, componente, validação no servidor, e uma checklist de estados (vazio, digitando, inválido, enviando, erro do servidor, sucesso). Ele suporta plataformas que usam React com shadcn/ui e é ideal para desenvolvedores que desejam implementar formulários acessíveis e resilientes com mínimo de atrito.

Funciona com~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ko-system/kodra-skills/tree/HEAD/skills/forms-validation

Installed? Explore more Programação e Desenvolvimento skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Formulários e validação

Stack: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui Componentes Form (já instalados no scaffold KODRA). Um único schema zod é a fonte única da verdade para tipos, validação no cliente e validação no servidor.

Regras

  1. Primeiro o schema. Defina um schema zod; derive o tipo TypeScript com z.infer. Coloque schemas compartilhados em src/lib/schemas/<nome>.ts para que as rotas do servidor possam importar o mesmo arquivo.
  2. Valide também no servidor. A validação no cliente é UX; a rota do servidor, função de borda ou restrição de banco de dados é o limite de segurança. Re-parseie o corpo com o mesmo schema e retorne erros de campo.
  3. Todo campo tem um label visível, uma descrição útil quando o formato não é óbvio e um erro inline abaixo do campo.
  4. Tempo de validação: mode: "onTouched" (validar após o primeiro blur, depois na mudança). Não grite com os usuários enquanto eles ainda estão digitando a primeira vez.
  5. Botão de envio permanece habilitado até o envio começar, então mostra um estado pendente e é desabilitado para evitar envios duplicados.
  6. Ao sucesso, mostre o que aconteceu (toast ou mensagem inline) e redefina ou navegue. Ao falhar, mantenha a entrada do usuário, mostre o erro próximo ao formulário e mova o foco para o primeiro campo inválido (react-hook-form faz isso por padrão).
  7. Use a entrada correta: type="email", inputMode="numeric", tokens autoComplete (name, email, tel, street-address, postal-code, one-time-code, new-password), para que navegadores e celulares ajudem.

Formulário canônico

// src/lib/schemas/contact.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().trim().min(2, "Digite seu nome completo"),
  email: z.string().trim().email("Digite um email válido, como [email protected]"),
  phone: z
    .string()
    .trim()
    .regex(
      /^0\d{8,9}$/,
      "Digite um número de telefone tailandês começando com 0 (9-10 dígitos)",
    )
    .optional()
    .or(z.literal("")),
  topic: z.enum(["booking", "billing", "other"], {
    required_error: "Escolha um assunto",
  }),
  message: z
    .string()
    .trim()
    .min(10, "Conte-nos um pouco mais (pelo menos 10 caracteres)")
    .max(2000),
  consent: z.boolean().refine((v) => v, "Por favor, aceite para continuar"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";

export function ContactForm() {
  const form = useForm<ContactInput>({
    resolver: zodResolver(contactSchema),
    mode: "onTouched",
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      message: "",
      consent: false,
    },
  });

  async function onSubmit(values: ContactInput) {
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (res.status === 422) {
      const { fieldErrors } = (await res.json()) as {
        fieldErrors: Partial<Record<keyof ContactInput, string>>;
      };
      for (const [field, message] of Object.entries(fieldErrors)) {
        form.setError(field as keyof ContactInput, { type: "server", message });
      }
      return;
    }
    if (!res.ok) {
      form.setError("root", {
        message:
          "Não foi possível enviar sua mensagem. Verifique sua conexão e tente novamente.",
      });
      return;
    }
    toast.success("Mensagem enviada. Responderemos em até um dia útil.");
    form.reset();
  }

  const { isSubmitting, errors } = form.formState;

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        noValidate
        className="grid gap-5 sm:grid-cols-2"
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Nome completo</FormLabel>
              <FormControl>
                <Input autoComplete="name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" autoComplete="email" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="topic"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Assunto</FormLabel>
              <Select onValueChange={field.onChange} value={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Escolha um assunto" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="booking">Reserva</SelectItem>
                  <SelectItem value="billing">Faturamento</SelectItem>
                  <SelectItem value="other">Outro</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="phone"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Telefone (opcional)</FormLabel>
              <FormControl>
                <Input
                  type="tel"
                  inputMode="tel"
                  autoComplete="tel"
                  {...field}
                />
              </FormControl>
              <FormDescription>Apenas se preferir uma ligação de volta.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem className="sm:col-span-2">
              <FormLabel>Mensagem</FormLabel>
              <FormControl>
                <Textarea rows={5} {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="consent"
          render={({ field }) => (
            <FormItem className="flex items-start gap-3 sm:col-span-2">
              <FormControl>
                <Checkbox
                  checked={field.value}
                  onCheckedChange={field.onChange}
                />
              </FormControl>
              <div className="space-y-1 leading-none">
                <FormLabel>
                  Aceito ser contatado sobre esta solicitação
                </FormLabel>
                <FormMessage />
              </div>
            </FormItem>
          )}
        />
        {errors.root && (
          <p role="alert" className="text-sm text-destructive sm:col-span-2">
            {errors.root.message}
          </p>
        )}
        <div className="sm:col-span-2">
          <Button
            type="submit"
            disabled={isSubmitting}
            className="w-full sm:w-auto"
          >
            {isSubmitting && (
              <Loader2
                className="mr-2 h-4 w-4 animate-spin"
                aria-hidden="true"
              />
            )}
            {isSubmitting ? "Enviando..." : "Enviar mensagem"}
          </Button>
        </div>
      </form>
    </Form>
  );
}

Notas:

  • noValidate desliga os balões nativos do navegador para que apenas as mensagens zod sejam exibidas.
  • Sempre passe defaultValues (strings vazias, não undefined) para que as entradas sejam controladas desde a primeira renderização.
  • Use value={field.value} em Select/RadioGroup (não defaultValue) para que form.reset() os limpe.
  • Quando o formulário for enviado por uma mutação TanStack Query, use mutation.isPending em vez de isSubmitting, e chame mutateAsync em onSubmit para que o react-hook-form aguarde.

Validação no servidor

Parseie com o mesmo schema e retorne 422 com erros de campo:

const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
  return Response.json(
    { fieldErrors: parsed.error.flatten().fieldErrors },
    { status: 422 },
  );
}

flatten().fieldErrors fornece string[] por campo; envie a primeira mensagem (fieldErrors.email?.[0]) ou adapte o loop do cliente. Restrições de banco de dados (email único, chaves estrangeiras) também são mapeadas para erros de campo: capture a violação única e retorne { fieldErrors: { email: "Este email já está registrado" } }. Em apps Supabase com acesso somente RLS, valide novamente em uma restrição de banco de dados ou em uma função de borda; nunca confie no cliente.

Estilo da mensagem de erro

  • Diga o que fazer, não o que deu errado: "Digite uma data posterior a hoje", não "Data inválida".
  • Use os termos do usuário e mostre o formato esperado inline ("DD/MM/AAAA").
  • Uma mensagem por campo por vez; não repita o label.
  • Escreva mensagens no idioma do app; mensagens zod são strings simples, então passe texto traduzido (veja o skill de localização para tailandês).

Checklist de estados

  • Vazio: labels, placeholders úteis (exemplos, não instruções).
  • Digitando: sem erros até o primeiro blur.
  • Inválido: borda vermelha + mensagem abaixo do campo (FormMessage), foco move para o primeiro erro ao enviar.
  • Enviando: botão desabilitado com spinner e label de progresso; entradas permanecem legíveis.
  • Erro do servidor: mensagem acima do botão de envio com role="alert"; entrada preservada.
  • Sucesso: toast ou confirmação inline; formulário redefinido ou redirecionado.
  • Alterações não salvas em formulários longos: avise antes de navegar (formState.isDirty).

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Habilidades Relacionadas