El FormularioReserva de la lección anterior funciona, pero acepta cualquier cosa: se puede enviar sin elegir bicicleta, con una fecha del año pasado, con cero horas o sin aceptar las condiciones. Un formulario que no valida no está terminado. Esta lección completa la otra mitad de la historia con dos temas que van de la mano: los componentes no controlados, la alternativa en la que el valor lo guarda el DOM y solo se lee al enviar, y la validación, tanto la que ofrece el navegador de serie como la que escribes tú en JavaScript. Aprenderás a decidir cuándo validar sin resultar molesto —el equilibrio entre avisar pronto y no gritarle a quien todavía está escribiendo—, a escribir la validación como una función pura fácil de probar y a mostrar los errores donde importa: junto al campo que los provoca.

Contenido

  1. Las dos maneras de guardar el valor de un campo
  2. Componentes no controlados: el DOM manda
  3. defaultValue y defaultChecked
  4. Leer los valores con FormData
  5. useRef como alternativa
  6. Comparativa: controlado frente a no controlado
  7. El caso especial de <input type="file">
  8. Validación nativa del navegador
  9. Validación en JavaScript: una función pura
  10. Cuándo validar y el concepto de campo «tocado»
  11. Mostrar los errores y bloquear el envío
  12. CicloUrbano: FormularioReserva validado
  13. Bibliotecas de formularios y esquemas

  1. Las dos maneras de guardar el valor de un campo

Solo hay dos respuestas posibles a la pregunta «¿dónde vive lo que la persona usuaria ha escrito?».

Enfoque Quién guarda el valor Cómo se lee Cómo se pone un valor inicial
Controlado El estado de React Del estado, siempre disponible useState(valorInicial)
No controlado El nodo del DOM Al leerlo explícitamente (FormData o ref) defaultValue / defaultChecked

Ya dominas el primero. El segundo no es «la forma antigua» ni una mala práctica: es la forma nativa de HTML, y en ciertos casos es la elección correcta. La clave está en entender qué se gana y qué se pierde con cada uno.

flowchart TD
    A["Alguien escribe en un campo"] --> B{"¿Controlado?"}
    B -- "Sí" --> C["onChange actualiza el estado"] --> D["Render nuevo"] --> E["El campo muestra el estado"]
    B -- "No" --> F["El DOM guarda el valor<br/>React no se entera"] --> G["Se lee solo al enviar"]

  1. Componentes no controlados: el DOM manda

Un campo no controlado es simplemente un campo sin value ni onChange: se comporta como en HTML de toda la vida.

function FormularioSencillo() {
  function manejarEnvio(evento) {
    evento.preventDefault();
    // El valor se lee AHORA, no antes
    const datos = new FormData(evento.target);
    console.log(datos.get('modelo'));
  }

  return (
    <form onSubmit={manejarEnvio}>
      <label htmlFor="modelo">Modelo</label>
      <input id="modelo" name="modelo" type="text" />
      <button type="submit">Guardar</button>
    </form>
  );
}

Fíjate en lo que no hay: ni useState, ni onChange, ni value. El componente no se vuelve a renderizar al escribir, porque nada cambia en React.

Y fíjate en lo que hay y ahora es imprescindible: el atributo name en cada campo. En un formulario controlado el name era una comodidad para el manejador genérico; aquí es la única forma de identificar el campo al leerlo.

Lo que se gana:

  • Menos código. Un formulario de diez campos no necesita diez entradas de estado.
  • Cero renders al escribir. En formularios enormes puede notarse.
  • Interoperabilidad. Encaja con código no-React y con bibliotecas de terceros que manipulan el DOM.

Lo que se pierde:

  • No puedes reaccionar a lo que se escribe. Nada de validación en tiempo real, contadores de caracteres, previsualizaciones ni transformaciones al vuelo.
  • No puedes deshabilitar el botón hasta que el formulario sea válido, porque no sabes qué contiene.
  • Reiniciar y rellenar programáticamente exige tocar el DOM.

  1. defaultValue y defaultChecked

Un campo no controlado también puede empezar con un valor. Pero no se usa value: eso lo convertiría en controlado (y provocaría el aviso del campo congelado que viste en 03-04).

{/* ✔ Valor inicial de un campo NO controlado */}
<input name="horas" type="number" defaultValue={2} />
<textarea name="observaciones" defaultValue="Sin observaciones" />
<select name="tipo" defaultValue="urbana">
  <option value="urbana">Urbana</option>
  <option value="electrica">Eléctrica</option>
</select>

{/* Para casillas y radios, defaultChecked */}
<input name="condiciones" type="checkbox" defaultChecked />
<input name="estado" type="radio" value="disponible" defaultChecked />
Prop Campo controlado Campo no controlado
Texto, área, desplegable value (+ onChange) defaultValue
Casilla y radio checked (+ onChange) defaultChecked

Hay un detalle que sorprende: defaultValue solo se aplica en el primer render. Si más adelante cambias esa prop, el campo no se actualiza, porque su valor ya lo gestiona el DOM. Si necesitas cambiar el valor desde fuera después de montar el componente, ese campo debe ser controlado.

  1. Leer los valores con FormData

FormData es una API del navegador —no de React— que recoge todos los campos de un formulario a partir de su atributo name. Es la forma más limpia de leer un formulario no controlado.

// src/componentes/FormularioIncidencia.jsx

/**
 * Parte de incidencia de una bicicleta, con campos NO controlados.
 * Props:
 *  - bicicletas (array, opcional, por defecto [])
 *  - alRegistrar (función, opcional): recibe el objeto de incidencia
 */
function FormularioIncidencia({ bicicletas = [], alRegistrar }) {
  function manejarEnvio(evento) {
    evento.preventDefault();

    // evento.target es el <form>; FormData recoge todos sus campos con name
    const formulario = evento.target;
    const datos = new FormData(formulario);

    const incidencia = {
      bicicletaId: datos.get('bicicletaId'),
      descripcion: datos.get('descripcion'),
      // get() SIEMPRE devuelve cadena (o null): hay que convertir
      urgencia: Number(datos.get('urgencia')),
      // Una casilla sin marcar NO aparece en FormData: get() devuelve null
      bloqueaUso: datos.get('bloqueaUso') === 'on'
    };

    if (alRegistrar) {
      alRegistrar(incidencia);
    }

    formulario.reset();   // en un formulario NO controlado, reset() sí es correcto
  }

  return (
    <form onSubmit={manejarEnvio}>
      <label htmlFor="bicicletaId">Bicicleta</label>
      <select id="bicicletaId" name="bicicletaId" defaultValue="">
        <option value="">— Elige una bicicleta —</option>
        {bicicletas.map((bicicleta) => (
          <option key={bicicleta.id} value={bicicleta.id}>
            {bicicleta.modelo} ({bicicleta.id})
          </option>
        ))}
      </select>

      <label htmlFor="descripcion">Descripción</label>
      <textarea id="descripcion" name="descripcion" rows={3} />

      <label htmlFor="urgencia">Urgencia (1-5)</label>
      <input id="urgencia" name="urgencia" type="number" min={1} max={5} defaultValue={3} />

      <label>
        <input name="bloqueaUso" type="checkbox" /> Impide el uso de la bicicleta
      </label>

      <button type="submit">Registrar incidencia</button>
    </form>
  );
}

export default FormularioIncidencia;

Tres detalles de FormData que causan errores si no se conocen:

Comportamiento Consecuencia
get() devuelve cadena o null Los números hay que convertirlos con Number()
Una casilla sin marcar no aparece get('bloqueaUso') devuelve null, no false
Solo recoge campos con name Un campo con id pero sin name se queda fuera, en silencio

Y dos atajos muy prácticos:

// Convertir todo el formulario en un objeto plano de una sola línea
const objeto = Object.fromEntries(new FormData(formulario));

// Recoger todos los valores de un campo repetido (casillas con el mismo name)
const tipos = new FormData(formulario).getAll('tipos');   // array de cadenas

Aquí sí es correcto formulario.reset(): como el valor lo guarda el DOM, vaciarlo es exactamente lo que hace falta. En un formulario controlado sería inútil, porque React repondría los valores del estado.

  1. useRef como alternativa

Existe otra forma de leer un campo no controlado: guardar una referencia al nodo del DOM.

import { useRef } from 'react';

function BuscadorRapido({ alBuscar }) {
  const campoBusqueda = useRef(null);   // arranca vacío

  function manejarEnvio(evento) {
    evento.preventDefault();
    alBuscar(campoBusqueda.current.value);   // acceso directo al nodo
  }

  return (
    <form onSubmit={manejarEnvio}>
      <input ref={campoBusqueda} type="text" name="busqueda" />
      <button type="submit">Buscar</button>
    </form>
  );
}

useRef crea un objeto con una propiedad current; al pasarlo como prop ref de un elemento, React coloca ahí el nodo real del DOM. Desde ese momento, campoBusqueda.current es el <input>, con todos sus métodos y propiedades.

¿Cuándo FormData y cuándo useRef?

Necesidad Herramienta
Leer todos los campos al enviar FormData: menos código, sin referencias que mantener
Leer un único campo suelto Cualquiera de las dos
Dar el foco a un campo, seleccionar su texto, hacer scroll hasta él useRef: son acciones sobre el nodo, no lecturas
Integrar una biblioteca externa que necesita el nodo useRef

useRef es mucho más que una forma de leer campos: es la vía de escape controlada de React hacia el DOM, y también sirve para guardar valores que persisten entre renders sin provocar renders. Se estudia a fondo en Hook useRef y Acceso al DOM; aquí basta con que sepas que existe y para qué se usa en formularios.

  1. Comparativa: controlado frente a no controlado

Criterio Controlado No controlado
Dónde vive el valor Estado de React Nodo del DOM
Props del campo value + onChange defaultValue (nada más)
Cómo se lee Del estado, en cualquier momento FormData o ref, al leerlo
Renders al escribir Uno por pulsación Ninguno
Validación en tiempo real No
Deshabilitar el envío si es inválido No (solo con validación nativa)
Transformar mientras se escribe No
Cambiar el valor desde fuera No sin tocar el DOM
Cantidad de código Más Menos
Facilidad para probar Alta: basta con el estado Media: hay que simular el DOM
Campos de fichero Imposible Obligatorio

Criterios para elegir, en orden de importancia:

  1. ¿Necesitas reaccionar a lo que se escribe? Validación al vuelo, contador de caracteres, botón que se habilita, previsualización, campos que dependen de otros. → Controlado.
  2. ¿Es un campo de fichero?No controlado, obligatoriamente (apartado 7).
  3. ¿Es un formulario grande y sencillo, que solo se lee al enviar? Un alta de veinte campos sin interdependencias. → No controlado es una opción razonable.
  4. ¿Se integra con código externo que toca el DOM?No controlado.
  5. En caso de dudaControlado. Es el valor por defecto de React y el que deja la puerta abierta a añadir comportamiento después.

En CicloUrbano seguiremos con controlados para el FormularioReserva, precisamente porque queremos validar mientras se rellena.

  1. El caso especial de <input type="file">

Los campos de fichero son la única excepción absoluta: siempre son no controlados, sin alternativa.

function SubidaFoto({ alSeleccionarFoto }) {
  function manejarCambio(evento) {
    const fichero = evento.target.files[0];   // FileList, no una cadena

    if (!fichero) {
      return;
    }

    console.log(fichero.name, fichero.size, fichero.type);
    alSeleccionarFoto(fichero);
  }

  return (
    <label>
      Foto del desperfecto:{' '}
      <input type="file" accept="image/*" onChange={manejarCambio} />
    </label>
  );
}

El motivo es de seguridad: si React pudiera fijar el value de un campo de fichero, una página maliciosa podría escribir ahí una ruta del disco y subir un archivo sin que nadie lo eligiera. Por eso el navegador prohíbe asignar ese valor por código: solo la persona usuaria puede cambiarlo eligiendo un fichero.

Las consecuencias prácticas:

  • No pongas value en un <input type="file">. React avisará.
  • El valor se lee de evento.target.files, un objeto FileList parecido a un array. Con multiple, contiene varios ficheros: Array.from(evento.target.files).
  • Sí puedes usar onChange para reaccionar a la selección: eso no lo convierte en controlado, porque no impones el valor.
  • Para vaciarlo, la vía habitual es reiniciar el formulario o cambiar la key del elemento para que React lo recree.

  1. Validación nativa del navegador

HTML trae validación de serie, sin una línea de JavaScript. Es gratis y conviene aprovecharla.

<form onSubmit={manejarEnvio}>
  <input name="email" type="email" required />
  <input name="horas" type="number" min={1} max={24} step={1} required />
  <input name="codigo" type="text" pattern="[A-Z]{3}-[0-9]{3}" title="Formato: ABC-123" />
  <input name="fechaInicio" type="datetime-local" required />
  <button type="submit">Enviar</button>
</form>
Atributo Qué comprueba
required Que el campo no esté vacío (o marcado, en una casilla)
type="email" Que el texto tenga forma de dirección de correo
type="url" Que el texto tenga forma de URL
min / max Rango en números y fechas
step Incrementos válidos: step={0.5}, step={1}
minLength / maxLength Longitud del texto
pattern Expresión regular que debe cumplir el valor
title Texto de ayuda que el navegador muestra al fallar pattern

Si algún campo no cumple, el navegador bloquea el envío —el onSubmit ni siquiera se ejecuta— y muestra un globo con un mensaje.

Sus límites, que son reales

Límite Detalle
Mensajes incontrolables El texto y el idioma los pone el navegador; el estilo del globo no se puede cambiar
Aspecto inconsistente Cada navegador lo muestra de forma distinta
Solo un error a la vez Muestra el primer campo inválido, no todos
No cubre reglas de negocio «La fecha no puede ser anterior a ahora», «esta bicicleta ya está alquilada»: imposible
No hay validación cruzada «La devolución debe ser posterior a la recogida» necesita JavaScript
Se puede esquivar Basta con manipular el HTML desde las herramientas del navegador
Accesibilidad limitada El globo no siempre se anuncia bien en los lectores de pantalla

Ese último punto sobre esquivar el filtro tiene una consecuencia que no hay que olvidar nunca: la validación del cliente es para la comodidad de quien rellena el formulario, no para la seguridad. El servidor debe volver a validar todo lo que recibe, siempre.

noValidate: desactivar la nativa

Cuando escribes tu propia validación y quieres controlar todos los mensajes, se desactiva la del navegador con el atributo noValidate en el <form>:

<form onSubmit={manejarEnvio} noValidate>

Es lo habitual en aplicaciones React con validación propia: los atributos required, min y max se mantienen en el marcado —porque comunican información útil a las tecnologías de asistencia— pero los mensajes los pones tú.

  1. Validación en JavaScript: una función pura

La mejor forma de validar es una función pura: recibe los datos y devuelve un objeto de errores, sin tocar el estado, sin leer el DOM y sin efectos secundarios.

// src/utilidades/validarReserva.js

const MAX_HORAS = 24;

/**
 * Valida los datos de una reserva de CicloUrbano.
 *
 * @param {Object} datos - { bicicletaId, fechaInicio, horas, condiciones }
 * @param {Array}  bicicletas - catálogo, para comprobar la disponibilidad
 * @returns {Object} objeto con un mensaje por cada campo con error.
 *                   Sin errores, devuelve un objeto vacío {}.
 */
export function validarReserva(datos, bicicletas = []) {
  const errores = {};

  // --- Bicicleta: obligatoria y disponible ---
  if (!datos.bicicletaId) {
    errores.bicicletaId = 'Elige una bicicleta.';
  } else {
    const bicicleta = bicicletas.find((b) => b.id === datos.bicicletaId);

    if (!bicicleta) {
      errores.bicicletaId = 'La bicicleta seleccionada no existe.';
    } else if (bicicleta.estado !== 'disponible') {
      errores.bicicletaId = `${bicicleta.modelo} no está disponible ahora mismo.`;
    }
  }

  // --- Fecha de inicio: obligatoria y no anterior a este momento ---
  if (!datos.fechaInicio) {
    errores.fechaInicio = 'Indica cuándo empieza la reserva.';
  } else {
    const inicio = new Date(datos.fechaInicio);

    if (Number.isNaN(inicio.getTime())) {
      errores.fechaInicio = 'La fecha no tiene un formato válido.';
    } else if (inicio.getTime() < Date.now()) {
      errores.fechaInicio = 'La reserva no puede empezar en el pasado.';
    }
  }

  // --- Horas: entero entre 1 y 24 ---
  const horas = Number(datos.horas);

  if (datos.horas === '' || Number.isNaN(horas)) {
    errores.horas = 'Indica cuántas horas quieres la bicicleta.';
  } else if (!Number.isInteger(horas)) {
    errores.horas = 'Las horas deben ser un número entero.';
  } else if (horas < 1) {
    errores.horas = 'La reserva mínima es de 1 hora.';
  } else if (horas > MAX_HORAS) {
    errores.horas = `La reserva máxima es de ${MAX_HORAS} horas.`;
  }

  // --- Condiciones: hay que aceptarlas ---
  if (!datos.condiciones) {
    errores.condiciones = 'Debes aceptar las condiciones de uso.';
  }

  return errores;
}

Por qué esta forma es la correcta:

  • Es pura. Con las mismas entradas devuelve siempre la misma salida. No hay estado, ni fechas ocultas, ni sorpresas.
  • Se prueba sin React. Es JavaScript normal: se le pasan objetos y se comprueba el resultado. Cuando llegues al Módulo 9 verás que este tipo de función es la más fácil de cubrir con pruebas.
  • Devuelve un objeto por campo, no un booleano ni una lista suelta. Así el formulario sabe dónde poner cada mensaje.
  • Un solo error por campo, el primero que se detecta. Encadenar else if evita abrumar con tres mensajes sobre el mismo campo.
  • Reglas de negocio incluidas. Comprobar que la bicicleta esté 'disponible' es algo que ningún atributo HTML puede hacer.
  • Vive en src/utilidades/, no dentro del componente: se puede reutilizar en otra pantalla y, llegado el caso, en el servidor.

Comprobar si el formulario es válido se reduce entonces a una línea:

const errores = validarReserva(datos, bicicletas);
const esValido = Object.keys(errores).length === 0;

  1. Cuándo validar y el concepto de campo «tocado»

Validar es fácil; validar en el momento adecuado es lo que distingue un formulario agradable de uno insufrible.

Imagina un campo de correo con validación en cada tecla. Al escribir la primera letra, «a», aparece en rojo: «El correo no es válido». Claro que no lo es: aún faltan quince caracteres. El mensaje es correcto y la experiencia, pésima.

Momento Ventaja Inconveniente Recomendado para
Al escribir (onChange) Reacción inmediata Grita antes de tiempo en campos vacíos o a medias Contraseñas con requisitos, contadores, campos ya corregidos
Al perder el foco (onBlur) La persona ha terminado con ese campo El error tarda en aparecer El momento por defecto
Al enviar (onSubmit) Nunca molesta durante el llenado Todos los errores aparecen de golpe al final Última red de seguridad, siempre obligatoria

La estrategia que usan las aplicaciones bien hechas combina las tres:

  1. Al perder el foco, se marca el campo como «tocado» y se muestra su error si lo tiene.
  2. Al escribir, solo se actualiza el error de los campos ya tocados. Así, cuando alguien está corrigiendo un error, ve desaparecer el mensaje en cuanto lo arregla.
  3. Al enviar, se marcan todos los campos como tocados y se muestran todos los errores pendientes.

Los campos «tocados»

Un campo está tocado cuando la persona ha interactuado con él y lo ha abandonado. Se guarda en un segundo estado, paralelo a los datos:

const [tocados, setTocados] = useState({});   // { bicicletaId: true, horas: true, … }

function manejarBlur(evento) {
  const { name } = evento.target;
  setTocados((anterior) => ({ ...anterior, [name]: true }));
}

Y la regla para mostrar un mensaje es la conjunción de dos condiciones:

{tocados.horas && errores.horas && <p className="error">{errores.horas}</p>}

Al enviar, se marcan todos de golpe:

function marcarTodosTocados(datos) {
  const todos = {};
  Object.keys(datos).forEach((campo) => {
    todos[campo] = true;
  });
  return todos;
}
flowchart TD
    A["La persona escribe"] --> B["Se actualizan los datos"]
    B --> C["Se recalculan los errores<br/>(valor derivado)"]
    C --> D{"¿El campo está tocado?"}
    D -- "No" --> E["No se muestra nada todavía"]
    D -- "Sí" --> F["Se muestra el mensaje del campo"]
    G["La persona sale del campo (blur)"] --> H["El campo pasa a tocado"] --> D
    I["Envío del formulario"] --> J["Todos los campos pasan a tocados"] --> D

Un punto conceptual importante: los errores no son estado. Se calculan con validarReserva(datos, bicicletas) en cada render a partir de los datos. Guardarlos en un useState crearía una segunda fuente de verdad que habría que recordar actualizar en cada cambio. Lo que sí es estado son los datos y los campos tocados, porque no se pueden derivar de nada. Es la distinción de la lección 02-04 aplicada a la validación.

  1. Mostrar los errores y bloquear el envío

Tres decisiones de diseño que conviene tomar conscientemente.

Dónde va el mensaje

Junto al campo que lo provoca, nunca en una lista al principio ni al final. Quien lee el mensaje necesita saber de inmediato qué campo debe corregir, sin buscar.

<div className={estilos.campo}>
  <label htmlFor="horas">Duración (horas)</label>
  <input id="horas" name="horas" type="number" value={datos.horas} onChange={manejarCambio} onBlur={manejarBlur} />
  {mostrarError('horas') && <p className={estilos.error}>{errores.horas}</p>}
</div>

¿Deshabilitar el botón de envío?

Es tentador poner disabled={!esValido}, y hay que pensarlo bien:

Enfoque A favor En contra
Botón deshabilitado Impide el envío inválido de forma evidente No explica por qué; quien lo ve no sabe qué falta. Los botones deshabilitados son problemáticos con lectores de pantalla
Botón activo + validar al enviar Al pulsar aparecen todos los errores explicados Permite un intento fallido
Enfoque mixto Botón activo, y al enviar se marcan todos los campos y se muestran los errores Es el recomendado

La recomendación práctica: deja el botón activo y valida en el envío mostrando todos los mensajes. Si tu diseño exige deshabilitarlo, añade siempre un texto visible que explique qué falta.

Estilos y señales

Un campo con error debe distinguirse por más de una señal: borde de color, icono y el texto del mensaje. El color por sí solo no basta —una parte de la población no distingue el rojo del verde— y esa idea, junto con la forma correcta de asociar el mensaje al campo para que un lector de pantalla lo anuncie (aria-invalid, aria-describedby, role="alert"), se desarrolla en la próxima lección, Accesibilidad en Componentes Interactivos. Aquí nos ocupamos de la lógica; allí, de que llegue a todo el mundo.

  1. CicloUrbano: FormularioReserva validado

Juntamos todo sobre el formulario de la lección anterior.

// src/componentes/FormularioReserva.jsx
import { useState } from 'react';
import { clases } from '../utilidades/clases.js';
import { validarReserva } from '../utilidades/validarReserva.js';
import estilos from './FormularioReserva.module.css';

const DATOS_INICIALES = {
  bicicletaId: '',
  fechaInicio: '',
  horas: 2,
  condiciones: false
};

/**
 * Formulario de creación de reservas de CicloUrbano, con validación.
 * Props:
 *  - bicicletas (array, opcional, por defecto []): catálogo completo
 *  - usuarioId (cadena, opcional, por defecto 'usr-01')
 *  - alCrearReserva (función, opcional): recibe el objeto Reserva validado
 *
 * Estado: `datos` (lo que se escribe) y `tocados` (con qué campos se ha interactuado).
 * Los ERRORES no son estado: se derivan de `datos` en cada render.
 */
function FormularioReserva({ bicicletas = [], usuarioId = 'usr-01', alCrearReserva }) {
  const [datos, setDatos] = useState(DATOS_INICIALES);
  const [tocados, setTocados] = useState({});

  // Valores derivados
  const errores = validarReserva(datos, bicicletas);
  const esValido = Object.keys(errores).length === 0;
  const disponibles = bicicletas.filter((bicicleta) => bicicleta.estado === 'disponible');
  const elegida = bicicletas.find((bicicleta) => bicicleta.id === datos.bicicletaId);
  const total = elegida ? elegida.precioHora * Number(datos.horas || 0) : 0;

  // Un error solo se enseña si el campo ya ha sido tocado
  function mostrarError(campo) {
    return Boolean(tocados[campo] && errores[campo]);
  }

  function manejarCambio(evento) {
    const { name, type, value, checked } = evento.target;

    let valorFinal = value;
    if (type === 'checkbox') {
      valorFinal = checked;
    } else if (type === 'number') {
      valorFinal = value === '' ? '' : Number(value);
    }

    setDatos((anterior) => ({ ...anterior, [name]: valorFinal }));
  }

  function manejarBlur(evento) {
    const { name } = evento.target;
    setTocados((anterior) => ({ ...anterior, [name]: true }));
  }

  function manejarEnvio(evento) {
    evento.preventDefault();

    // Al enviar, todos los campos pasan a tocados: se ven todos los errores
    const todosTocados = {};
    Object.keys(DATOS_INICIALES).forEach((campo) => {
      todosTocados[campo] = true;
    });
    setTocados(todosTocados);

    if (!esValido) {
      return;   // no se envía nada mientras haya errores
    }

    const reserva = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: datos.bicicletaId,
      usuario: usuarioId,
      fechaInicio: datos.fechaInicio,
      horas: Number(datos.horas),
      estado: 'activa'
    };

    if (alCrearReserva) {
      alCrearReserva(reserva);
    }

    setDatos(DATOS_INICIALES);
    setTocados({});
  }

  return (
    // noValidate: desactivamos los globos del navegador y usamos nuestros mensajes
    <form className={estilos.formulario} onSubmit={manejarEnvio} noValidate>
      <h2>Nueva reserva</h2>

      <div className={estilos.campo}>
        <label htmlFor="bicicletaId">Bicicleta</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          required
          value={datos.bicicletaId}
          onChange={manejarCambio}
          onBlur={manejarBlur}
          className={clases(estilos.control, mostrarError('bicicletaId') && estilos.invalido)}
        >
          <option value="">— Elige una bicicleta —</option>
          {disponibles.map((bicicleta) => (
            <option key={bicicleta.id} value={bicicleta.id}>
              {bicicleta.modelo} · {bicicleta.precioHora.toFixed(2).replace('.', ',')} €/h
            </option>
          ))}
        </select>
        {mostrarError('bicicletaId') && (
          <p className={estilos.error}>{errores.bicicletaId}</p>
        )}
      </div>

      <div className={estilos.campo}>
        <label htmlFor="fechaInicio">Inicio de la reserva</label>
        <input
          id="fechaInicio"
          name="fechaInicio"
          type="datetime-local"
          required
          value={datos.fechaInicio}
          onChange={manejarCambio}
          onBlur={manejarBlur}
          className={clases(estilos.control, mostrarError('fechaInicio') && estilos.invalido)}
        />
        {mostrarError('fechaInicio') && (
          <p className={estilos.error}>{errores.fechaInicio}</p>
        )}
      </div>

      <div className={estilos.campo}>
        <label htmlFor="horas">Duración (horas)</label>
        <input
          id="horas"
          name="horas"
          type="number"
          min={1}
          max={24}
          step={1}
          required
          value={datos.horas}
          onChange={manejarCambio}
          onBlur={manejarBlur}
          className={clases(estilos.control, mostrarError('horas') && estilos.invalido)}
        />
        {mostrarError('horas') && <p className={estilos.error}>{errores.horas}</p>}
      </div>

      <div className={estilos.campoCasilla}>
        <label>
          <input
            name="condiciones"
            type="checkbox"
            checked={datos.condiciones}
            onChange={manejarCambio}
            onBlur={manejarBlur}
          />{' '}
          Acepto las condiciones de uso de CicloUrbano
        </label>
        {mostrarError('condiciones') && (
          <p className={estilos.error}>{errores.condiciones}</p>
        )}
      </div>

      {elegida && esValido && (
        <p className={estilos.total}>
          {elegida.modelo} · {datos.horas} h ·{' '}
          <strong>{total.toFixed(2).replace('.', ',')} €</strong>
        </p>
      )}

      <button type="submit" className={estilos.enviar}>
        Crear reserva
      </button>
    </form>
  );
}

export default FormularioReserva;

Comportamiento resultante, paso a paso:

Acción Qué se ve
Se abre el formulario Ningún error: nada está tocado todavía
Se abre el desplegable y se cierra sin elegir Al perder el foco: «Elige una bicicleta.»
Se elige una bicicleta El mensaje desaparece en cuanto el dato es válido
Se escribe 0 en las horas y se sale del campo «La reserva mínima es de 1 hora.»
Se corrige a 2 El mensaje desaparece al escribir, porque el campo ya estaba tocado
Se pulsa «Crear reserva» con todo vacío Aparecen los cuatro mensajes a la vez y no se envía nada
Se envía correctamente El padre recibe la Reserva; el formulario y los tocados se reinician

Y un detalle que ilustra el valor de las reglas de negocio: el desplegable solo lista bicicletas disponible, pero validarReserva vuelve a comprobarlo. Si mientras se rellena el formulario esa bicicleta pasara a estar alquilada, el mensaje sería «Eléctrica Pro no está disponible ahora mismo». Ningún atributo HTML puede hacer eso.

  1. Bibliotecas de formularios y esquemas

En proyectos con muchos formularios, el código de esta lección se repite. El ecosistema tiene dos familias de herramientas que conviene conocer de nombre:

Herramienta Qué aporta
React Hook Form Gestiona valores, tocados y errores con campos mayormente no controlados, reduciendo mucho los renders y el código repetitivo
Formik La opción clásica, basada en componentes controlados; muy extendida en código existente
Zod Define un esquema de datos y valida contra él, generando los mensajes; se integra con las anteriores
Yup Alternativa a Zod, anterior y también muy usada

No las vamos a desarrollar: todas se apoyan en los conceptos que acabas de aprender —controlado y no controlado, campos tocados, errores por campo, validación en el envío— y adoptarlas sin entender el mecanismo lleva a no saber depurarlas cuando algo falla. Cuando llegues a un proyecto que use React Hook Form con Zod, reconocerás cada pieza.

Errores Comunes y Consejos

  • Poner value en un campo no controlado. Lo convierte en controlado y lo congela. Para valores iniciales, defaultValue / defaultChecked.
  • Olvidar el atributo name en un formulario no controlado. FormData ignora los campos sin name: el valor no aparece y no hay ningún error.
  • Esperar que FormData.get() devuelva números o booleanos. Siempre devuelve cadena o null. Convierte explícitamente, y recuerda que una casilla sin marcar no aparece.
  • Poner value en un <input type="file">. Es imposible por seguridad; el valor se lee de evento.target.files.
  • Guardar los errores en el estado. Son un valor derivado de los datos. Guardarlos crea una segunda fuente de verdad que se desincroniza.
  • Validar en cada tecla desde el primer carácter. Molesta muchísimo. Muestra el error solo cuando el campo esté «tocado».
  • Validar únicamente al escribir y no al enviar. Quien no toca un campo nunca ve su error. El envío debe marcar todos los campos como tocados.
  • Confiar solo en la validación del cliente. Se puede esquivar desde las herramientas del navegador. El servidor tiene que validar siempre.
  • Deshabilitar el botón sin explicar por qué. Deja a la persona sin pistas de qué le falta. Prefiere validar al enviar y mostrar los mensajes.
  • Usar pattern con expresiones regulares complicadas para el correo. Ninguna es completamente correcta. Usa type="email" y confirma con un envío real.
  • Poner el mensaje de error lejos del campo. Debe ir inmediatamente debajo o al lado.
  • Mezclar la validación nativa y la propia sin noValidate. El navegador bloquea el envío antes de que se ejecute tu onSubmit y tus mensajes no llegan a mostrarse nunca.
  • Consejo: escribe la validación como una función pura en src/utilidades/. Se prueba sin React, se reutiliza y documenta las reglas de negocio en un solo sitio.
  • Consejo: un mensaje por campo y en tono útil. «La reserva mínima es de 1 hora» explica qué hacer; «Valor inválido» no.
  • Consejo: mantén required, min y max en el marcado aunque uses noValidate. Comunican información a las tecnologías de asistencia y documentan el campo.

Ejercicios

Ejercicio 1

Este formulario mezcla los dos enfoques y tiene cuatro problemas. Identifícalos, explica el síntoma de cada uno y decide si conviene hacerlo controlado o no controlado, justificando la elección.

function FormularioDevolucion({ estaciones }) {
  const [estacion, setEstacion] = useState('');

  function manejarEnvio(evento) {
    evento.preventDefault();
    const datos = new FormData(evento.target);
    console.log({
      estacion,
      kilometros: datos.get('kilometros'),
      incidencia: datos.get('incidencia'),
      foto: datos.get('foto')
    });
    evento.target.reset();
  }

  return (
    <form onSubmit={manejarEnvio}>
      <select value={estacion}>
        {estaciones.map((e) => <option key={e.id} value={e.id}>{e.nombre}</option>)}
      </select>

      <input type="number" defaultValue={0} />

      <input type="checkbox" name="incidencia" />

      <input type="file" name="foto" value="" />

      <button type="submit">Devolver</button>
    </form>
  );
}

Ejercicio 2

Escribe la función pura validarEstacion(datos) en src/utilidades/validarEstacion.js, que valide el alta de una estación de CicloUrbano. Debe devolver un objeto de errores por campo con estas reglas:

  • nombre: obligatorio, entre 3 y 40 caracteres, y no puede coincidir (ignorando mayúsculas y espacios sobrantes) con el nombre de una estación existente, que llegará como segundo parámetro.
  • barrio: obligatorio, y debe ser uno de 'Centro', 'Norte' o 'Ensanche'.
  • plazas: entero entre 5 y 60.
  • enServicio: si es false, debe haber un motivo de al menos 10 caracteres.

Escribe además tres casos de prueba a mano (entrada y salida esperada) que demuestren que funciona.

Ejercicio 3

Convierte el FormularioIncidencia del apartado 4 —que es no controlado— en un formulario controlado con validación, aplicando el patrón completo de campos tocados. Las reglas:

  • bicicletaId: obligatoria.
  • descripcion: obligatoria, mínimo 15 caracteres, máximo 500, con contador de caracteres restantes.
  • urgencia: entero entre 1 y 5.
  • Si bloqueaUso está marcado, la urgencia debe ser 4 o 5.

Explica por qué esta última regla es imposible de expresar con validación nativa del navegador.

Soluciones

Solución 1.

Los cuatro problemas:

Problema Síntoma
El <select> tiene value pero no onChange El desplegable queda congelado en la opción inicial y React avisa de campo de solo lectura
El campo numérico no tiene name FormData no lo recoge: datos.get('kilometros') devuelve null siempre
<input type="file" value=""> Prohibido por seguridad; React avisa. Además, FormData.get('foto') devuelve un objeto File, no una cadena
evento.target.reset() con un campo controlado El select no se reinicia: React repone el valor del estado en el render siguiente

Sobre el enfoque: conviene hacerlo todo no controlado salvo que se necesite reaccionar a lo que se escribe. Es un parte de devolución que se lee entero al enviar, no tiene campos interdependientes ni validación en tiempo real, e incluye un campo de fichero, que obligatoriamente es no controlado. Mezclar los dos enfoques en el mismo formulario es lo que ha producido tres de los cuatro fallos.

// src/componentes/FormularioDevolucion.jsx

/**
 * Parte de devolución de una bicicleta. Formulario NO controlado:
 * los valores los guarda el DOM y se leen con FormData al enviar.
 * Props:
 *  - estaciones (array, opcional, por defecto [])
 *  - alDevolver (función, opcional): recibe el parte de devolución
 */
function FormularioDevolucion({ estaciones = [], alDevolver }) {
  function manejarEnvio(evento) {
    evento.preventDefault();

    const formulario = evento.target;
    const datos = new FormData(formulario);

    const parte = {
      estacionId: datos.get('estacionId'),
      kilometros: Number(datos.get('kilometros')),
      // Una casilla sin marcar no aparece en FormData
      incidencia: datos.get('incidencia') === 'on',
      foto: datos.get('foto')   // objeto File, o un File vacío si no se eligió nada
    };

    if (alDevolver) {
      alDevolver(parte);
    }

    formulario.reset();   // correcto: todos los campos son no controlados
  }

  return (
    <form onSubmit={manejarEnvio}>
      <label htmlFor="estacionId">Estación de devolución</label>
      <select id="estacionId" name="estacionId" defaultValue="" required>
        <option value="">— Elige una estación —</option>
        {estaciones.map((estacion) => (
          <option key={estacion.id} value={estacion.id}>
            {estacion.nombre} ({estacion.barrio})
          </option>
        ))}
      </select>

      <label htmlFor="kilometros">Kilómetros recorridos</label>
      <input id="kilometros" name="kilometros" type="number" min={0} defaultValue={0} />

      <label>
        <input name="incidencia" type="checkbox" /> Hay una incidencia que reportar
      </label>

      <label htmlFor="foto">Foto (opcional)</label>
      <input id="foto" name="foto" type="file" accept="image/*" />

      <button type="submit">Devolver</button>
    </form>
  );
}

export default FormularioDevolucion;

Solución 2.

// src/utilidades/validarEstacion.js

const BARRIOS_VALIDOS = ['Centro', 'Norte', 'Ensanche'];
const MIN_PLAZAS = 5;
const MAX_PLAZAS = 60;

function normalizar(texto) {
  return String(texto ?? '').trim().toLowerCase();
}

/**
 * Valida el alta de una estación de CicloUrbano.
 *
 * @param {Object} datos - { nombre, barrio, plazas, enServicio, motivo }
 * @param {Array} existentes - estaciones ya registradas, para evitar duplicados
 * @returns {Object} un mensaje por campo con error; {} si todo es válido
 */
export function validarEstacion(datos, existentes = []) {
  const errores = {};

  // --- Nombre ---
  const nombre = String(datos.nombre ?? '').trim();

  if (nombre === '') {
    errores.nombre = 'El nombre de la estación es obligatorio.';
  } else if (nombre.length < 3) {
    errores.nombre = 'El nombre debe tener al menos 3 caracteres.';
  } else if (nombre.length > 40) {
    errores.nombre = 'El nombre no puede superar los 40 caracteres.';
  } else if (existentes.some((estacion) => normalizar(estacion.nombre) === normalizar(nombre))) {
    errores.nombre = `Ya existe una estación llamada «${nombre}».`;
  }

  // --- Barrio ---
  if (!datos.barrio) {
    errores.barrio = 'Elige un barrio.';
  } else if (!BARRIOS_VALIDOS.includes(datos.barrio)) {
    errores.barrio = `El barrio debe ser uno de: ${BARRIOS_VALIDOS.join(', ')}.`;
  }

  // --- Plazas ---
  const plazas = Number(datos.plazas);

  if (datos.plazas === '' || Number.isNaN(plazas)) {
    errores.plazas = 'Indica el número de plazas.';
  } else if (!Number.isInteger(plazas)) {
    errores.plazas = 'Las plazas deben ser un número entero.';
  } else if (plazas < MIN_PLAZAS || plazas > MAX_PLAZAS) {
    errores.plazas = `Las plazas deben estar entre ${MIN_PLAZAS} y ${MAX_PLAZAS}.`;
  }

  // --- Motivo, solo si la estación no entra en servicio ---
  if (datos.enServicio === false) {
    const motivo = String(datos.motivo ?? '').trim();

    if (motivo.length < 10) {
      errores.motivo = 'Explica en al menos 10 caracteres por qué no entra en servicio.';
    }
  }

  return errores;
}

Los tres casos de prueba:

const existentes = [
  { id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 },
  { id: 'est-02', nombre: 'Parque Norte', barrio: 'Norte', plazas: 15 }
];

// Caso 1: todo correcto -> {}
validarEstacion(
  { nombre: 'Mercado Viejo', barrio: 'Ensanche', plazas: 25, enServicio: true },
  existentes
);

// Caso 2: nombre duplicado con distinta caja y espacios, plazas fuera de rango
validarEstacion(
  { nombre: '  plaza mayor  ', barrio: 'Centro', plazas: 100, enServicio: true },
  existentes
);
// -> {
//      nombre: 'Ya existe una estación llamada «plaza mayor».',
//      plazas: 'Las plazas deben estar entre 5 y 60.'
//    }

// Caso 3: fuera de servicio sin motivo suficiente
validarEstacion(
  { nombre: 'Puente Sur', barrio: 'Ensanche', plazas: 12, enServicio: false, motivo: 'obras' },
  existentes
);
// -> { motivo: 'Explica en al menos 10 caracteres por qué no entra en servicio.' }

La función normalizar centraliza la comparación —recortar espacios y pasar a minúsculas— para que la regla se aplique igual en todos los sitios. Y devolver {} cuando todo es válido permite la comprobación de una línea: Object.keys(errores).length === 0.

Solución 3.

// src/componentes/FormularioIncidencia.jsx
import { useState } from 'react';

const MAX_DESCRIPCION = 500;
const MIN_DESCRIPCION = 15;

const DATOS_INICIALES = {
  bicicletaId: '',
  descripcion: '',
  urgencia: 3,
  bloqueaUso: false
};

function validarIncidencia(datos) {
  const errores = {};

  if (!datos.bicicletaId) {
    errores.bicicletaId = 'Elige la bicicleta afectada.';
  }

  const descripcion = datos.descripcion.trim();
  if (descripcion.length === 0) {
    errores.descripcion = 'Describe la incidencia.';
  } else if (descripcion.length < MIN_DESCRIPCION) {
    errores.descripcion = `Describe la incidencia con al menos ${MIN_DESCRIPCION} caracteres.`;
  } else if (descripcion.length > MAX_DESCRIPCION) {
    errores.descripcion = `La descripción no puede superar los ${MAX_DESCRIPCION} caracteres.`;
  }

  const urgencia = Number(datos.urgencia);
  if (datos.urgencia === '' || Number.isNaN(urgencia)) {
    errores.urgencia = 'Indica el nivel de urgencia.';
  } else if (!Number.isInteger(urgencia) || urgencia < 1 || urgencia > 5) {
    errores.urgencia = 'La urgencia debe ser un entero entre 1 y 5.';
  } else if (datos.bloqueaUso && urgencia < 4) {
    // Validación CRUZADA entre dos campos
    errores.urgencia = 'Si la incidencia impide el uso, la urgencia debe ser 4 o 5.';
  }

  return errores;
}

/**
 * Parte de incidencia de una bicicleta, controlado y validado.
 * Props:
 *  - bicicletas (array, opcional, por defecto [])
 *  - alRegistrar (función, opcional): recibe la incidencia validada
 */
function FormularioIncidencia({ bicicletas = [], alRegistrar }) {
  const [datos, setDatos] = useState(DATOS_INICIALES);
  const [tocados, setTocados] = useState({});

  const errores = validarIncidencia(datos);
  const esValido = Object.keys(errores).length === 0;
  const restantes = MAX_DESCRIPCION - datos.descripcion.length;

  function mostrarError(campo) {
    return Boolean(tocados[campo] && errores[campo]);
  }

  function manejarCambio(evento) {
    const { name, type, value, checked } = evento.target;

    let valorFinal = value;
    if (type === 'checkbox') {
      valorFinal = checked;
    } else if (type === 'number') {
      valorFinal = value === '' ? '' : Number(value);
    }

    setDatos((anterior) => ({ ...anterior, [name]: valorFinal }));
  }

  function manejarBlur(evento) {
    const { name } = evento.target;
    setTocados((anterior) => ({ ...anterior, [name]: true }));
  }

  function manejarEnvio(evento) {
    evento.preventDefault();

    const todos = {};
    Object.keys(DATOS_INICIALES).forEach((campo) => {
      todos[campo] = true;
    });
    setTocados(todos);

    if (!esValido) {
      return;
    }

    if (alRegistrar) {
      alRegistrar({ ...datos, descripcion: datos.descripcion.trim() });
    }

    setDatos(DATOS_INICIALES);
    setTocados({});
  }

  return (
    <form onSubmit={manejarEnvio} noValidate>
      <div>
        <label htmlFor="bicicletaId">Bicicleta</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          value={datos.bicicletaId}
          onChange={manejarCambio}
          onBlur={manejarBlur}
        >
          <option value="">— Elige una bicicleta —</option>
          {bicicletas.map((bicicleta) => (
            <option key={bicicleta.id} value={bicicleta.id}>
              {bicicleta.modelo} ({bicicleta.id})
            </option>
          ))}
        </select>
        {mostrarError('bicicletaId') && <p className="error">{errores.bicicletaId}</p>}
      </div>

      <div>
        <label htmlFor="descripcion">Descripción</label>
        <textarea
          id="descripcion"
          name="descripcion"
          rows={3}
          maxLength={MAX_DESCRIPCION}
          value={datos.descripcion}
          onChange={manejarCambio}
          onBlur={manejarBlur}
        />
        <small>Quedan {restantes} caracteres.</small>
        {mostrarError('descripcion') && <p className="error">{errores.descripcion}</p>}
      </div>

      <div>
        <label htmlFor="urgencia">Urgencia (1-5)</label>
        <input
          id="urgencia"
          name="urgencia"
          type="number"
          min={1}
          max={5}
          value={datos.urgencia}
          onChange={manejarCambio}
          onBlur={manejarBlur}
        />
        {mostrarError('urgencia') && <p className="error">{errores.urgencia}</p>}
      </div>

      <label>
        <input
          name="bloqueaUso"
          type="checkbox"
          checked={datos.bloqueaUso}
          onChange={manejarCambio}
          onBlur={manejarBlur}
        />{' '}
        Impide el uso de la bicicleta
      </label>

      <button type="submit">Registrar incidencia</button>
    </form>
  );
}

export default FormularioIncidencia;

Por qué la última regla es imposible de forma nativa: los atributos de validación de HTML son locales a un campo. min={1} y max={5} solo saben del valor de ese <input>; no existe ningún atributo que diga «el mínimo de este campo depende de si aquella casilla está marcada». Es una validación cruzada entre dos campos, es decir, una regla de negocio, y para eso hace falta JavaScript. Fíjate además en que el enfoque de función pura la absorbe sin esfuerzo: es un else if más dentro de la validación de urgencia.

Conclusión

Con esta lección los formularios de React quedan completos. Has visto la alternativa a los componentes controlados: los no controlados, en los que el valor lo guarda el DOM, se declara con defaultValue o defaultChecked y se lee al enviar con FormData —recordando que devuelve cadenas, que las casillas sin marcar no aparecen y que solo recoge campos con name— o con useRef, la vía de acceso directo al nodo que se estudiará a fondo en el Módulo 5. Sabes elegir entre ambos enfoques con criterio: controlado siempre que necesites reaccionar a lo que se escribe, no controlado para formularios grandes que solo se leen al final, y obligatoriamente no controlado en el <input type="file">, que por razones de seguridad no admite que el código fije su valor.

En validación has aprendido que el navegador ofrece gratis un primer filtro con required, min, max, type="email" y pattern, pero que sus mensajes no se pueden controlar, solo muestra un error a la vez, no expresa reglas de negocio ni validaciones cruzadas y se puede esquivar —de ahí que el servidor deba validar siempre—. Por eso la validación seria se escribe como una función pura en src/utilidades/, que recibe los datos y devuelve un objeto con un mensaje por campo: se prueba sin React, se reutiliza y concentra las reglas del dominio en un solo sitio. Y sobre todo has aprendido cuándo validar: los errores se calculan siempre, pero solo se muestran cuando el campo está tocado, con el envío marcando todos los campos de golpe. Los errores no son estado; los datos y los tocados sí.

CicloUrbano tiene ahora un FormularioReserva que no deja crear reservas en el pasado, de cero horas, sobre bicicletas no disponibles ni sin aceptar las condiciones, y que dice exactamente qué falla y dónde. Queda una pregunta pendiente: esos mensajes se ven, pero ¿se oyen? Un lector de pantalla no relaciona automáticamente un párrafo rojo con el campo de arriba, un <article> con onClick no se puede activar con el teclado y una etiqueta de estado que solo se distingue por el color deja fuera a quien no percibe ese color. Todo eso —y cómo comprobarlo— es lo que cierra el módulo en Accesibilidad en Componentes Interactivos.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados