Un formulario de fecha de nacimiento validado en Next.js con entradas controladas y callbacks
Crea un pequeño formulario cliente de Next.js que haga seguimiento de los datos ingresados con useState, rechace fechas de nacimiento inválidas, muestre errores accesibles y entregue datos limpios al componente padre.
Una aplicación de horóscopos necesita dos cosas del usuario antes de poder generar una lectura: un nombre y una fecha de nacimiento. Parece un formulario de cinco minutos, pero incluso este pequeño componente exige decisiones reales de diseño: dónde se ejecutará dentro de una aplicación Next.js, quién será el responsable de los valores ingresados, cómo se rechazarán las fechas inválidas y quién decidirá qué sucede después de un envío exitoso.
Esta guía construye ese formulario paso a paso. Al final, tendrás un componente de formulario controlado que valida sus entradas, muestra los errores de manera accesible y solo transmite datos limpios hacia arriba, además de un modelo mental claro que puedes reutilizar para cualquier formulario con más de un campo.
Determina qué es responsabilidad del componente
Antes de escribir cualquier JSX, conviene listar las funciones del componente. Este formulario tiene exactamente tres:
- Recordar lo que el usuario ha escrito.
- Verificar esa entrada antes de enviarla.
Cualquier acción fuera de esa lista, como llamar a una API o mostrar los resultados de la lectura, pertenece a otro lugar. Mantener la lista corta hace que el resto del diseño sea sencillo.
Por qué el formulario debe ser un componente cliente
En App Router, cada componente es un componente servidor a menos que se elija lo contrario. Los componentes servidor se renderizan en el servidor y no incluyen JavaScript interactivo, por lo que no pueden almacenar estado ni reaccionar a eventos. Por eso el formulario comienza con la directiva client.
"use client";
El componente depende de varios elementos que solo existen en el navegador:
useStatepara los valores actuales,- manejadores
onChangeen los campos de entrada, - un manejador
onSubmiten el formulario, - interacción continua con el usuario,
En resumen, el componente no solo muestra información; también debe responder a las acciones del usuario. Ese es el indicio para considerarlo un Componente Cliente. Es una buena práctica mantener tales componentes pequeños y en las ramas más profundas del árbol, de modo que la directiva no incluya grandes partes de la página en el paquete del cliente.
Escribe los datos y las propiedades
El formulario importa un tipo compartido Profile junto con useState.
import { Profile } from "../types";
import { useState } from "react";
Declarar explícitamente la estructura de los datos enviados permite a TypeScript verificar cada lugar donde se generan o consumen, en lugar de permitir que un objeto arbitrario circule por la aplicación.
export type Profile ={
name: string;
dob: string;
}
A continuación vienen las propiedades del componente. Solo hay una: una función de callback proporcionada por el padre.
type HoroscopeFormProps = {
onSubmit: (info: Profile) => void;
};
Deja que el padre decida qué sucede a continuación
Este prop es donde se dibuja el límite del componente. El formulario recopila datos, pero no le corresponde decidir qué hacer con ellos. Dependiendo de la pantalla, el componente padre podría:
- llamar a una API,
- generar el horóscopo,
- guardar el perfil,
- mostrar el resultado,
- actualizar algún otro estado.
Si se codifican de forma fija alguna de esas opciones dentro del formulario, este quedaría vinculado a una sola pantalla. En cambio, al aceptar una función onSubmit, se mantiene su reutilización. El tipo indica que onSubmit recibe un objeto Profile y no devuelve nada (void); por lo tanto, el formulario la ejecuta y continúa. El flujo general es el siguiente:
User enters information
↓
HoroscopeForm collects it
↓
HoroscopeForm validates it
↓
onSubmit(user)
↓
Parent decides what happens next
Cada paso tiene un único responsable, y la función del formulario finaliza en el momento en que llama a la función de callback.
Almacenar la entrada en el estado de React
El formulario necesita un lugar donde almacenar los valores actuales. Tres elementos de estado cubren todo.
const [name, setName] = useState<string>("");
const [dob, setDob] = useState<string>("");
const [error, setError] = useState<string>("");
Mire primero el nombre.
const [name, setName] = useState<string>("");
useState devuelve un par. name es el valor actual, y setName es la función que se llama para reemplazarlo, lo cual también programa una nueva renderización. El valor inicial es una cadena vacía porque aún no se ha ingresado nada. Inicializar con una cadena en lugar de undefined es importante para los inputs controlados: React emite una advertencia si un input pasa de ser no controlado a controlado cuando su value cambia de undefined a una cadena.
La fecha de nacimiento sigue el mismo patrón.
const [dob, setDob] = useState<string>("");
El último elemento almacena el mensaje de error actual.
const [error, setError] = useState<string>("");
Una cadena vacía significa que en este momento no hay error. La validación escribirá un mensaje en este estado cuando haya algún problema y lo eliminará una vez que la entrada sea válida.
Mantener la validación de fechas en una función separada
Las reglas de validación tienden a aumentar, por lo que en lugar de acumularlas en el manejador de envío, la verificación de fechas se encuentra en una función dedicada. Su firma indica cuáles son sus requisitos.
function validateDOB(dob: string): string | null {
Existen exactamente dos resultados posibles. Una fecha inválida genera una cadena que explica el problema; una fecha válida genera null.
Valid date
↓
return null
Invalid date
↓
return error message
Dado que la función responde a una sola pregunta, “¿es esta fecha de nacimiento aceptable?”, es fácil de leer, sencilla de probar con pruebas unitarias sin mostrar nada y fácil de reutilizar en un servidor si posteriormente también se realiza la validación allí. Devolver el mensaje en lugar de lanzar una excepción mantiene simple el código que lo llama: basta con verificar el resultado y mostrarlo si está presente.
Fechas que deben rechazarse
Existen dos reglas razonables para una fecha de nacimiento:
- Ninguna fecha futura. Nadie puede haber nacido en un día que aún no ha ocurrido.
- Límite inferior razonable. Las fechas de hace más de 150 años son rechazadas, ya que casi con certeza se introdujo un error al escribirlas.
Las fechas parecen sencillas hasta que entra en juego la hora del día. Un <input type="date"> devuelve una cadena en el formato YYYY-MM-DD, y new Date("2024-05-01") interpreta esa cadena como la medianoche en UTC, mientras que “hoy”, generado con new Date(), incluye las horas, minutos y segundos locales. Dependiendo de la zona horaria del usuario, una comparación simplista puede aceptar erróneamente el día siguiente o rechazar hoy. Dos opciones fiables son normalizar ambos valores al inicio del día antes de compararlos, o bien comparar directamente las cadenas YYYY-MM-DD, ya que se ordenan correctamente como texto. Sea cual sea el enfoque que elijas, y sin importar si un asistente de IA te ayudó a redactarlo, asegúrate de poder explicar por qué se realiza cada comparación; los errores relacionados con las fechas suelen ocultarse justo en las líneas que nadie comprendió.
Coordina todo en el manejador de envío
Con el estado y la validación listos, handleSubmit los une. Cuando el usuario envía el formulario, debe:
- detener la acción de envío por defecto del navegador, que recargaría o redirigiría la página,
- confirmar que ambos campos tengan valores,
- validar la fecha de nacimiento,
- mostrar un error si algo no está correcto,
- de lo contrario, pasar los datos al componente padre.
Comienza de esta manera.
const handleSubmit = (e: React.SubmitEvent) => {
e.preventDefault();
Por defecto, el envío de un formulario envía una solicitud y recarga la página. Dado que React se encarga del envío aquí, ese comportamiento por defecto debe ser anulado.
e.preventDefault();
A partir de este momento, el componente por sí solo decide qué hace la acción de envío. Una nota sobre el tipo de evento: muchos repositorios de código asignan este parámetro como React.FormEvent<HTMLFormElement>. Verifique qué tipos de eventos de envío expone la versión instalada de @types/react y elija aquel que utilice consistentemente su proyecto.
Rechazar campos vacíos con un return anticipado
Antes de verificar si la fecha tiene sentido, confirme que se haya ingresado algo al menos.
if (!name || !dob) {
setError("Please enter in information");
return;
}
Si alguno de los campos está vacío, el manejador registra un error y devuelve inmediatamente. Este es el patrón de retorno temprano (o cláusula de protección): una vez que se sabe que la entrada es inválida, no queda nada por hacer, por lo que la función termina en lugar de envolver la lógica restante en otro nivel de bloques if. Cada cláusula de protección maneja un fallo y el “camino exitoso” permanece simple al final.
Ejecutar la verificación de fecha
Una vez que se sabe que existe una fecha, esta pasa por el validador.
const dobError = validateDOB(dob);
El resultado es o bien un mensaje o null, por lo que basta con una sola verificación.
if (dobError) {
setError(dobError);
return;
}
Un mensaje significa que el manejador lo muestra y se detiene. null significa que la fecha es válida y la ejecución continúa.
Entregar datos limpios al padre
Al llegar a este punto significa que todas las verificaciones se han superado, por lo que cualquier error antiguo de un intento anterior queda resuelto.
setError("");
Luego, la función de callback del padre recibe el perfil validado.
onSubmit({ name, dob });
Este es el resultado de la decisión de diseño tomada anteriormente. El formulario no sabe ni le importa qué sucederá a continuación; simplemente indica que hay datos válidos disponibles, y el padre decide qué hacer. El mismo componente podría alimentar un generador de horóscopos hoy y una pantalla de configuración de perfil mañana sin necesidad de cambios.
Vincular la lógica al marcado
El elemento de formulario conecta el envío con el manejador correspondiente.
<form onSubmit={handleSubmit}>
Esto indica a React que ejecute handleSubmit cada vez que se envía el formulario, ya sea al hacer clic en el botón o al presionar Enter en un campo. A continuación viene el campo de nombre.
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
Cómo se mantiene sincronizado un input controlado
Se trata de una entrada controlada: el estado de React, y no el DOM, es la fuente de verdad para su valor. Cada vez que el usuario escribe, se ejecuta el manejador de cambios.
onChange={(e) => setName(e.target.value)}
Lee el nuevo texto del evento y lo almacena en el estado. El bucle completo se ve así:
User types
↓
onChange fires
↓
setName(new value)
↓
name state updates
↓
value={name}
↓
Input displays updated value
Dado que la entrada siempre muestra lo que contiene name, se garantiza que el valor que se valida sea el mismo que aparece en la pantalla. El campo de fecha utiliza el mismo patrón.
<input
type="date"
value={dob}
onChange={(e) => setDob(e.target.value)}
/>
El estado registra la fecha seleccionada, y cada cambio llama a setDob. Una adición que merece consideración es el atributo nativo max establecido en la fecha de hoy, lo cual impide que la mayoría de los selectores de fechas ofrezcan días futuros, mientras que tu validador sigue protegiendo contra entradas incorrectas y navegadores antiguos.
Cada campo de entrada también debe tener un <label> visible asociado a él. Un marcador de posición o un encabezado cercano no son sustitutos; el label es lo que los lectores de pantalla anuncian y lo que hace que el campo sea clickeable gracias a su leyenda.
Mostrar errores solo cuando existan
El mensaje de error debe aparecer únicamente cuando haya uno. El renderizado condicional se encarga de eso.
{error && (
<p role="alert">
{error}
</p>
)}
Cuando error contiene texto, se renderiza el párrafo; cuando es una cadena vacía, que se considera falsa, no aparece nada. Este atajo && es seguro aquí porque el valor es una cadena. Con números puede fallar: un conteo de 0 se renderizaría como el literal “0”.
El párrafo también lleva un rol ARIA.
role="alert"
role="alert" indica a la tecnología de asistencia que este contenido es importante y urgente, por lo que los lectores de pantalla lo anuncian en cuanto aparece. Se trata de un cambio en un solo atributo que permite que las respuestas de validación sean útiles para quienes no pueden ver la notificación. Para mayor claridad, también se puede marcar el campo problemático con aria-invalid y vincularlo al mensaje con aria-describedby.
Agregar el botón de envío
El último elemento es un botón declarado explícitamente como botón de envío.
<button type="submit">
Submit
</button>
Dentro de un formulario, un botón con type="submit" activa el método onSubmit del formulario y, con él, también handleSubmit. Los botones dentro de un formulario tienen como valor predeterminado enviar los datos, pero especificar el tipo evita sorpresas cuando alguien añade posteriormente un segundo botón destinado a otra función, como borrar los campos.
El flujo de datos completo
Visto en su conjunto, el componente transfiere los datos en una sola dirección:
State
↓
User input
↓
Submit
↓
Validation
↓
Parent callback
useStatealmacena lo que ingresó el usuario.- Los campos de entrada actualizan ese estado cada vez que cambian.
- Al enviar el formulario se ejecuta
handleSubmit. handleSubmitvalida los valores.- Los datos inválidos establecen el estado de error y detienen el proceso.
- Los datos válidos se envían al componente padre a través de
onSubmit, y el padre los recibe desde allí.
A dónde ir desde aquí
Este enfoque manual es ideal para el aprendizaje y perfectamente adecuado para un formulario con dos campos. A medida que los formularios crecen y pasan a tener muchos campos, así como reglas entre campos o verificaciones en el servidor, considere utilizar una biblioteca de esquemas para que las mismas reglas se apliquen tanto en el cliente como en el servidor; compartir un esquema Zod entre el frontend React y el backend Node es una forma de hacerlo. La validación en el cliente mejora la experiencia, pero nunca reemplaza a la validación en el servidor, ya que cualquier solicitud puede ser manipulada manualmente.
Puntos clave
- Solo marque los componentes interactivos con
"use client"y manténgalos pequeños. - Dé al formulario una única función: recopilar, validar y pasar los datos. Deje que el componente padre se encargue de los efectos secundarios a través de una función de callback tipada.
null; son fáciles de probar y reutilizar.YYYY-MM-DD para evitar errores por diferencias en zonas horarias.role="alert" junto con etiquetas adecuadas para que los errores sean accesibles.Lecturas relacionadas
- Formularios React hechos a mano: inputs controlados, validación y estados de envío — Aprenda cómo funcionan los formularios en React, desde inputs controlados y manejadores por tipo hasta la validación, los estados de envío, campos dinámicos, subidas de archivos y cuándo resulta útil una biblioteca de formularios.
- Construyendo una página de detalle de película resiliente con Next.js App Router — Aprenda cómo obtener y almacenar en caché los datos de la API OMDB correctamente en Next.js App Router, utilizando componentes de servidor asíncronos, parámetros esperados y un manejo adecuado del error 404.