CommunityProgramación y desarrollogithub.com

ko-system/forms-validation

Formularios accesibles con react-hook-form, zod y shadcn/ui Form, que cubren errores en línea, estados pendientes y deshabilitados, retroalimentación de éxito y validación del lado del servidor con esquemas compartidos.

¿Qué es forms-validation?

Esta habilidad proporciona una guía completa y canonica para implementar formularios accesibles y robustos en aplicaciones React que utilizan el stack react-hook-form + zod + shadcn/ui. Su enfoque principal es 'Schema First', donde un único esquema Zod actúa como fuente de verdad para los tipos TypeScript, la validación del lado del cliente y la validación del lado del servidor. La habilidad detalla reglas estrictas como: validar tanto en cliente como en servidor (re-parsing el cuerpo de la solicitud con el mismo esquema), mostrar errores visibles y en línea, manejar estados de envío (pendiente, deshabilitado, éxito y fallo), y usar modos de validación como 'onTouched' para no molestar al usuario mientras escribe. Incluye ejemplos de código completos con componentes de shadcn/ui (Input, Textarea, Select, Checkbox) y explica cómo gestionar errores del servidor (422), estados de envío, y resetear el formulario. También aborda la validación en el servidor, el estilo de los mensajes de error (decir qué hacer, no qué falló), y una lista de verificación de estados (vació, tipeo, inválido, enviando, error de servidor, éxito). Es ideal para desarrolladores que buscan una solución consistente y accesible para formularios en proyectos React con TypeScript, especialmente en entornos como KODRA o Supabase.

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

Installed? Explore more Programación y desarrollo skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Formularios y validación

Stack: react-hook-form + zod + @hookform/resolvers/zod + componentes shadcn/ui Form (ya instalados en el scaffold de KODRA). Un solo esquema zod es la fuente única de verdad para los tipos, la validación del lado del cliente y la validación del lado del servidor.

Reglas

  1. Primero el esquema. Define un esquema zod; deriva el tipo TypeScript con z.infer. Coloca los esquemas compartidos en src/lib/schemas/<nombre>.ts para que las rutas del servidor puedan importar el mismo archivo.
  2. Valida también en el servidor. La validación del cliente es UX; la ruta del servidor, la función edge o la restricción de la base de datos es el límite de seguridad. Vuelve a analizar el cuerpo con el mismo esquema y devuelve los errores de campo.
  3. Cada campo tiene una etiqueta visible, una descripción útil cuando el formato no es obvio, y un error en línea debajo del campo.
  4. Momento de la validación: mode: "onTouched" (validar después del primer desenfoque, luego al cambiar). No grites a los usuarios mientras escriben por primera vez.
  5. El botón de enviar permanece habilitado hasta que comienza el envío, luego muestra un estado pendiente y se deshabilita para evitar envíos dobles.
  6. En caso de éxito muestra lo que sucedió (toast o mensaje en línea) y reinicia o navega. En caso de fallo conserva la entrada del usuario, muestra el error cerca del formulario y mueve el foco al primer campo inválido (react-hook-form lo hace por defecto).
  7. Usa la entrada correcta: type="email", inputMode="numeric", tokens de autoComplete (name, email, tel, street-address, postal-code, one-time-code, new-password), para que los navegadores y teléfonos ayuden.

Formulario canónico

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

export const contactoSchema = z.object({
  nombre: z.string().trim().min(2, "Ingresa tu nombre completo"),
  email: z.string().trim().email("Ingresa un correo válido, como [email protected]"),
  telefono: z
    .string()
    .trim()
    .regex(
      /^0\d{8,9}$/,
      "Ingresa un número de teléfono tailandés que comience con 0 (9-10 dígitos)",
    )
    .optional()
    .or(z.literal("")),
  tema: z.enum(["reserva", "facturacion", "otro"], {
    required_error: "Elige un tema",
  }),
  mensaje: z
    .string()
    .trim()
    .min(10, "Cuéntanos un poco más (al menos 10 caracteres)")
    .max(2000),
  consentimiento: z.boolean().refine((v) => v, "Por favor acepta para continuar"),
});
export type ContactoInput = z.infer<typeof contactoSchema>;
// src/components/FormularioContacto.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 { contactoSchema, type ContactoInput } from "@/lib/schemas/contacto";

export function FormularioContacto() {
  const form = useForm<ContactoInput>({
    resolver: zodResolver(contactoSchema),
    mode: "onTouched",
    defaultValues: {
      nombre: "",
      email: "",
      telefono: "",
      mensaje: "",
      consentimiento: false,
    },
  });

  async function onSubmit(values: ContactoInput) {
    const res = await fetch("/api/contacto", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (res.status === 422) {
      const { erroresCampo } = (await res.json()) as {
        erroresCampo: Partial<Record<keyof ContactoInput, string>>;
      };
      for (const [campo, mensaje] of Object.entries(erroresCampo)) {
        form.setError(campo as keyof ContactoInput, { type: "server", message: mensaje });
      }
      return;
    }
    if (!res.ok) {
      form.setError("root", {
        message:
          "No pudimos enviar tu mensaje. Verifica tu conexión e inténtalo de nuevo.",
      });
      return;
    }
    toast.success("Mensaje enviado. Responderemos dentro de un día hábil.");
    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="nombre"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Nombre completo</FormLabel>
              <FormControl>
                <Input autoComplete="name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Correo electrónico</FormLabel>
              <FormControl>
                <Input type="email" autoComplete="email" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="tema"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Tema</FormLabel>
              <Select onValueChange={field.onChange} value={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Elige un tema" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="reserva">Reserva</SelectItem>
                  <SelectItem value="facturacion">Facturación</SelectItem>
                  <SelectItem value="otro">Otro</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="telefono"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Teléfono (opcional)</FormLabel>
              <FormControl>
                <Input
                  type="tel"
                  inputMode="tel"
                  autoComplete="tel"
                  {...field}
                />
              </FormControl>
              <FormDescription>Solo si prefieres que te llamemos.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="mensaje"
          render={({ field }) => (
            <FormItem className="sm:col-span-2">
              <FormLabel>Mensaje</FormLabel>
              <FormControl>
                <Textarea rows={5} {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="consentimiento"
          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>
                  Acepto ser contactado sobre esta solicitud
                </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 mensaje"}
          </Button>
        </div>
      </form>
    </Form>
  );
}

Notas:

  • noValidate desactiva las burbujas nativas del navegador para que solo se muestren los mensajes de zod.
  • Siempre pasa defaultValues (cadenas vacías, no undefined) para que los inputs estén controlados desde el primer renderizado.
  • Usa value={field.value} en Select/RadioGroup (no defaultValue) para que form.reset() los limpie.
  • Cuando el formulario se envía a través de una mutación de TanStack Query, usa mutation.isPending en lugar de isSubmitting, y llama mutateAsync en onSubmit para que react-hook-form espere.

Validación del servidor

Analiza con el mismo esquema y devuelve 422 con errores de campo:

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

flatten().fieldErrors devuelve string[] por campo; envía el primer mensaje (erroresCampo.email?.[0]) o adapta el bucle del cliente. Las restricciones de la base de datos (email único, claves foráneas) también se mapean a errores de campo: captura la violación de unicidad y devuelve { erroresCampo: { email: "Este correo ya está registrado" } }. En aplicaciones Supabase con acceso solo RLS, valida nuevamente en una restricción de base de datos o una función edge; nunca confíes en el cliente.

Estilo del mensaje de error

  • Di qué hacer, no qué salió mal: "Ingresa una fecha posterior a hoy", no "Fecha inválida".
  • Usa los términos del usuario y muestra el formato esperado en línea ("DD/MM/AAAA").
  • Un mensaje por campo a la vez; no repitas la etiqueta.
  • Escribe los mensajes en el idioma de la aplicación; los mensajes de zod son cadenas de texto simples, así que pasa texto traducido (consulta la habilidad de localización al tailandés).

Lista de verificación de estados

  • Vacío: etiquetas, placeholders útiles (ejemplos, no instrucciones).
  • Escribiendo: sin errores hasta el primer desenfoque.
  • Inválido: borde rojo + mensaje debajo del campo (FormMessage), el foco se mueve al primer error al enviar.
  • Enviando: botón deshabilitado con spinner y etiqueta de progreso; los inputs permanecen legibles.
  • Error del servidor: mensaje arriba del botón de enviar con role="alert"; la entrada se conserva.
  • Éxito: toast o confirmación en línea; reinicio del formulario o redirección.
  • Cambios no guardados en formularios largos: advertir antes de navegar (formState.isDirty).

Individual skills in this repo

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

Skills relacionados