El FormulariReserva de la lliçó anterior funciona, però accepta qualsevol cosa: es pot enviar sense triar bicicleta, amb una data de l'any passat, amb zero hores o sense acceptar les condicions. Un formulari que no valida no està acabat. Aquesta lliçó completa l'altra meitat de la història amb dos temes que van agafats de la mà: els components no controlats, l'alternativa en què el valor el guarda el DOM i només es llegeix en enviar, i la validació, tant la que ofereix el navegador de sèrie com la que escrius tu en JavaScript. Aprendràs a decidir quan validar sense resultar molest —l'equilibri entre avisar aviat i no cridar a qui encara està escrivint—, a escriure la validació com una funció pura fàcil de provar i a mostrar els errors on importa: al costat del camp que els provoca.

Contingut

  1. Les dues maneres de guardar el valor d'un camp
  2. Components no controlats: el DOM mana
  3. defaultValue i defaultChecked
  4. Llegir els valors amb FormData
  5. useRef com a alternativa
  6. Comparativa: controlat enfront de no controlat
  7. El cas especial de <input type="file">
  8. Validació nativa del navegador
  9. Validació en JavaScript: una funció pura
  10. Quan validar i el concepte de camp «tocat»
  11. Mostrar els errors i bloquejar l'enviament
  12. CicloUrbano: FormulariReserva validat
  13. Biblioteques de formularis i esquemes

  1. Les dues maneres de guardar el valor d'un camp

Només hi ha dues respostes possibles a la pregunta «on viu el que la persona usuària ha escrit?».

Enfocament Qui guarda el valor Com es llegeix Com es posa un valor inicial
Controlat L'estat de React De l'estat, sempre disponible useState(valorInicial)
No controlat El node del DOM En llegir-lo explícitament (FormData o ref) defaultValue / defaultChecked

Ja domines el primer. El segon no és «la forma antiga» ni una mala pràctica: és la forma nativa d'HTML, i en certs casos és l'elecció correcta. La clau és entendre què es guanya i què es perd amb cadascun.

flowchart TD
    A["Algú escriu en un camp"] --> B{"Controlat?"}
    B -- "Sí" --> C["onChange actualitza l'estat"] --> D["Render nou"] --> E["El camp mostra l'estat"]
    B -- "No" --> F["El DOM guarda el valor<br/>React no se n'assabenta"] --> G["Es llegeix només en enviar"]

  1. Components no controlats: el DOM mana

Un camp no controlat és simplement un camp sense value ni onChange: es comporta com en l'HTML de tota la vida.

function FormulariSenzill() {
  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    // El valor es llegeix ARA, no abans
    const dades = new FormData(esdeveniment.target);
    console.log(dades.get('model'));
  }

  return (
    <form onSubmit={gestionarEnviament}>
      <label htmlFor="model">Model</label>
      <input id="model" name="model" type="text" />
      <button type="submit">Desar</button>
    </form>
  );
}

Fixa't en el que no hi ha: ni useState, ni onChange, ni value. El component no es torna a renderitzar en escriure, perquè no canvia res a React.

I fixa't en el que que hi ha i ara és imprescindible: l'atribut name a cada camp. En un formulari controlat, el name era una comoditat per al gestor genèric; aquí és l'única manera d'identificar el camp en llegir-lo.

El que es guanya:

  • Menys codi. Un formulari de deu camps no necessita deu entrades d'estat.
  • Zero renders en escriure. En formularis enormes es pot notar.
  • Interoperabilitat. Encaixa amb codi no-React i amb biblioteques de tercers que manipulen el DOM.

El que es perd:

  • No pots reaccionar al que s'escriu. Res de validació en temps real, comptadors de caràcters, previsualitzacions ni transformacions al vol.
  • No pots deshabilitar el botó fins que el formulari sigui vàlid, perquè no saps què conté.
  • Reiniciar i emplenar de manera programàtica exigeix tocar el DOM.

  1. defaultValue i defaultChecked

Un camp no controlat també pot començar amb un valor. Però no s'utilitza value: això el convertiria en controlat (i provocaria l'avís del camp congelat que vas veure a 03-04).

{/* ✔ Valor inicial d'un camp NO controlat */}
<input name="hores" type="number" defaultValue={2} />
<textarea name="observacions" defaultValue="Sense observacions" />
<select name="tipus" defaultValue="urbana">
  <option value="urbana">Urbana</option>
  <option value="electrica">Elèctrica</option>
</select>

{/* Per a caselles i ràdios, defaultChecked */}
<input name="condicions" type="checkbox" defaultChecked />
<input name="estat" type="radio" value="disponible" defaultChecked />
Prop Camp controlat Camp no controlat
Text, àrea, desplegable value (+ onChange) defaultValue
Casella i ràdio checked (+ onChange) defaultChecked

Hi ha un detall que sorprèn: defaultValue només s'aplica en el primer render. Si més endavant canvies aquesta prop, el camp no s'actualitza, perquè el seu valor ja el gestiona el DOM. Si necessites canviar el valor des de fora després de muntar el component, aquest camp ha de ser controlat.

  1. Llegir els valors amb FormData

FormData és una API del navegador —no de React— que recull tots els camps d'un formulari a partir del seu atribut name. És la forma més neta de llegir un formulari no controlat.

// src/components/FormulariIncidencia.jsx

/**
 * Part d'incidència d'una bicicleta, amb camps NO controlats.
 * Props:
 *  - bicicletes (array, opcional, per defecte [])
 *  - alRegistrar (funció, opcional): rep l'objecte d'incidència
 */
function FormulariIncidencia({ bicicletes = [], alRegistrar }) {
  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();

    // esdeveniment.target és el <form>; FormData recull tots els seus camps amb name
    const formulari = esdeveniment.target;
    const dades = new FormData(formulari);

    const incidencia = {
      bicicletaId: dades.get('bicicletaId'),
      descripcio: dades.get('descripcio'),
      // get() SEMPRE retorna cadena (o null): cal convertir
      urgencia: Number(dades.get('urgencia')),
      // Una casella sense marcar NO apareix a FormData: get() retorna null
      bloquejaUs: dades.get('bloquejaUs') === 'on'
    };

    if (alRegistrar) {
      alRegistrar(incidencia);
    }

    formulari.reset();   // en un formulari NO controlat, reset() sí que és correcte
  }

  return (
    <form onSubmit={gestionarEnviament}>
      <label htmlFor="bicicletaId">Bicicleta</label>
      <select id="bicicletaId" name="bicicletaId" defaultValue="">
        <option value="">— Tria una bicicleta —</option>
        {bicicletes.map((bicicleta) => (
          <option key={bicicleta.id} value={bicicleta.id}>
            {bicicleta.model} ({bicicleta.id})
          </option>
        ))}
      </select>

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

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

      <label>
        <input name="bloquejaUs" type="checkbox" /> Impedeix l'ús de la bicicleta
      </label>

      <button type="submit">Registrar incidència</button>
    </form>
  );
}

export default FormulariIncidencia;

Tres detalls de FormData que causen errors si no es coneixen:

Comportament Conseqüència
get() retorna cadena o null Els nombres cal convertir-los amb Number()
Una casella sense marcar no apareix get('bloquejaUs') retorna null, no false
Només recull camps amb name Un camp amb id però sense name es queda fora, en silenci

I dues dreceres molt pràctiques:

// Convertir tot el formulari en un objecte pla d'una sola línia
const objecte = Object.fromEntries(new FormData(formulari));

// Recollir tots els valors d'un camp repetit (caselles amb el mateix name)
const tipus = new FormData(formulari).getAll('tipus');   // array de cadenes

Aquí sí que és correcte formulari.reset(): com que el valor el guarda el DOM, buidar-lo és exactament el que cal fer. En un formulari controlat seria inútil, perquè React tornaria a posar els valors de l'estat.

  1. useRef com a alternativa

Hi ha una altra manera de llegir un camp no controlat: guardar una referència al node del DOM.

import { useRef } from 'react';

function CercadorRapid({ alBuscar }) {
  const campCerca = useRef(null);   // comença buit

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    alBuscar(campCerca.current.value);   // accés directe al node
  }

  return (
    <form onSubmit={gestionarEnviament}>
      <input ref={campCerca} type="text" name="cerca" />
      <button type="submit">Cercar</button>
    </form>
  );
}

useRef crea un objecte amb una propietat current; en passar-lo com a prop ref d'un element, React hi col·loca el node real del DOM. A partir d'aquest moment, campCerca.current és l'<input>, amb tots els seus mètodes i propietats.

Quan FormData i quan useRef?

Necessitat Eina
Llegir tots els camps en enviar FormData: menys codi, sense referències que mantenir
Llegir un únic camp solt Qualsevol de les dues
Donar el focus a un camp, seleccionar-ne el text, fer scroll fins a ell useRef: són accions sobre el node, no lectures
Integrar una biblioteca externa que necessita el node useRef

useRef és molt més que una manera de llegir camps: és la via d'escapament controlada de React cap al DOM, i també serveix per guardar valors que persisteixen entre renders sense provocar renders. S'estudia a fons a Hook useRef i Accés al DOM; aquí n'hi ha prou que sàpigues que existeix i per a què es fa servir en formularis.

  1. Comparativa: controlat enfront de no controlat

Criteri Controlat No controlat
On viu el valor Estat de React Node del DOM
Props del camp value + onChange defaultValue (res més)
Com es llegeix De l'estat, en qualsevol moment FormData o ref, en llegir-lo
Renders en escriure Un per pulsació Cap
Validació en temps real No
Deshabilitar l'enviament si no és vàlid No (només amb validació nativa)
Transformar mentre s'escriu No
Canviar el valor des de fora No sense tocar el DOM
Quantitat de codi Més Menys
Facilitat per provar Alta: n'hi ha prou amb l'estat Mitjana: cal simular el DOM
Camps de fitxer Impossible Obligatori

Criteris per triar, en ordre d'importància:

  1. Necessites reaccionar al que s'escriu? Validació al vol, comptador de caràcters, botó que s'habilita, previsualització, camps que depenen d'altres. → Controlat.
  2. És un camp de fitxer?No controlat, obligatòriament (apartat 7).
  3. És un formulari gran i senzill, que només es llegeix en enviar? Una alta de vint camps sense interdependències. → No controlat és una opció raonable.
  4. S'integra amb codi extern que toca el DOM?No controlat.
  5. En cas de dubteControlat. És el valor per defecte de React i el que deixa la porta oberta a afegir comportament més endavant.

A CicloUrbano seguirem amb controlats per al FormulariReserva, precisament perquè volem validar mentre s'emplena.

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

Els camps de fitxer són l'única excepció absoluta: sempre són no controlats, sense alternativa.

function PujadaFoto({ alSeleccionarFoto }) {
  function gestionarCanvi(esdeveniment) {
    const fitxer = esdeveniment.target.files[0];   // FileList, no una cadena

    if (!fitxer) {
      return;
    }

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

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

El motiu és de seguretat: si React pogués fixar el value d'un camp de fitxer, una pàgina maliciosa podria escriure-hi una ruta del disc i pujar un arxiu sense que ningú l'hagués triat. Per això el navegador prohibeix assignar aquest valor per codi: només la persona usuària el pot canviar triant un fitxer.

Les conseqüències pràctiques:

  • No posis value en un <input type="file">. React avisarà.
  • El valor es llegeix de esdeveniment.target.files, un objecte FileList semblant a un array. Amb multiple, conté diversos fitxers: Array.from(esdeveniment.target.files).
  • Sí que pots fer servir onChange per reaccionar a la selecció: això no el converteix en controlat, perquè no imposes el valor.
  • Per buidar-lo, la via habitual és reiniciar el formulari o canviar la key de l'element perquè React el recreï.

  1. Validació nativa del navegador

HTML porta validació de sèrie, sense una línia de JavaScript. És gratis i convé aprofitar-la.

<form onSubmit={gestionarEnviament}>
  <input name="email" type="email" required />
  <input name="hores" type="number" min={1} max={24} step={1} required />
  <input name="codi" type="text" pattern="[A-Z]{3}-[0-9]{3}" title="Format: ABC-123" />
  <input name="dataInici" type="datetime-local" required />
  <button type="submit">Enviar</button>
</form>
Atribut Què comprova
required Que el camp no estigui buit (o marcat, en una casella)
type="email" Que el text tingui forma d'adreça de correu
type="url" Que el text tingui forma d'URL
min / max Rang en nombres i dates
step Increments vàlids: step={0.5}, step={1}
minLength / maxLength Longitud del text
pattern Expressió regular que ha de complir el valor
title Text d'ajuda que el navegador mostra quan falla pattern

Si algun camp no compleix, el navegador bloqueja l'enviament —l'onSubmit ni tan sols s'executa— i mostra un globus amb un missatge.

Els seus límits, que són reals

Límit Detall
Missatges incontrolables El text i l'idioma els posa el navegador; l'estil del globus no es pot canviar
Aspecte inconsistent Cada navegador el mostra de manera diferent
Només un error alhora Mostra el primer camp no vàlid, no tots
No cobreix regles de negoci «La data no pot ser anterior a ara», «aquesta bicicleta ja està llogada»: impossible
No hi ha validació creuada «La devolució ha de ser posterior a la recollida» necessita JavaScript
Es pot esquivar N'hi ha prou de manipular l'HTML des de les eines del navegador
Accessibilitat limitada El globus no sempre s'anuncia bé als lectors de pantalla

Aquest últim punt sobre esquivar el filtre té una conseqüència que no cal oblidar mai: la validació del client és per a la comoditat de qui emplena el formulari, no per a la seguretat. El servidor ha de tornar a validar tot el que rep, sempre.

noValidate: desactivar la nativa

Quan escrius la teva pròpia validació i vols controlar tots els missatges, es desactiva la del navegador amb l'atribut noValidate al <form>:

<form onSubmit={gestionarEnviament} noValidate>

És l'habitual en aplicacions React amb validació pròpia: els atributs required, min i max es mantenen al marcatge —perquè comuniquen informació útil a les tecnologies d'assistència— però els missatges els poses tu.

  1. Validació en JavaScript: una funció pura

La millor manera de validar és una funció pura: rep les dades i retorna un objecte d'errors, sense tocar l'estat, sense llegir el DOM i sense efectes secundaris.

// src/utilitats/validarReserva.js

const MAX_HORES = 24;

/**
 * Valida les dades d'una reserva de CicloUrbano.
 *
 * @param {Object} dades - { bicicletaId, dataInici, hores, condicions }
 * @param {Array}  bicicletes - catàleg, per comprovar la disponibilitat
 * @returns {Object} objecte amb un missatge per cada camp amb error.
 *                   Sense errors, retorna un objecte buit {}.
 */
export function validarReserva(dades, bicicletes = []) {
  const errors = {};

  // --- Bicicleta: obligatòria i disponible ---
  if (!dades.bicicletaId) {
    errors.bicicletaId = 'Tria una bicicleta.';
  } else {
    const bicicleta = bicicletes.find((b) => b.id === dades.bicicletaId);

    if (!bicicleta) {
      errors.bicicletaId = 'La bicicleta seleccionada no existeix.';
    } else if (bicicleta.estat !== 'disponible') {
      errors.bicicletaId = `${bicicleta.model} no està disponible ara mateix.`;
    }
  }

  // --- Data d'inici: obligatòria i no anterior a aquest moment ---
  if (!dades.dataInici) {
    errors.dataInici = 'Indica quan comença la reserva.';
  } else {
    const inici = new Date(dades.dataInici);

    if (Number.isNaN(inici.getTime())) {
      errors.dataInici = 'La data no té un format vàlid.';
    } else if (inici.getTime() < Date.now()) {
      errors.dataInici = 'La reserva no pot començar en el passat.';
    }
  }

  // --- Hores: enter entre 1 i 24 ---
  const hores = Number(dades.hores);

  if (dades.hores === '' || Number.isNaN(hores)) {
    errors.hores = 'Indica quantes hores vols la bicicleta.';
  } else if (!Number.isInteger(hores)) {
    errors.hores = 'Les hores han de ser un nombre enter.';
  } else if (hores < 1) {
    errors.hores = 'La reserva mínima és d\'1 hora.';
  } else if (hores > MAX_HORES) {
    errors.hores = `La reserva màxima és de ${MAX_HORES} hores.`;
  }

  // --- Condicions: cal acceptar-les ---
  if (!dades.condicions) {
    errors.condicions = 'Has d\'acceptar les condicions d\'ús.';
  }

  return errors;
}

Per què aquesta forma és la correcta:

  • És pura. Amb les mateixes entrades sempre retorna la mateixa sortida. No hi ha estat, ni dates ocultes, ni sorpreses.
  • Es prova sense React. És JavaScript normal: se li passen objectes i es comprova el resultat. Quan arribis al Mòdul 9 veuràs que aquest tipus de funció és la més fàcil de cobrir amb proves.
  • Retorna un objecte per camp, no un booleà ni una llista solta. Així el formulari sap on posar cada missatge.
  • Un sol error per camp, el primer que es detecta. Encadenar else if evita aclaparar amb tres missatges sobre el mateix camp.
  • Regles de negoci incloses. Comprovar que la bicicleta estigui 'disponible' és una cosa que cap atribut HTML pot fer.
  • Viu a src/utilitats/, no dins del component: es pot reutilitzar en una altra pantalla i, arribat el cas, al servidor.

Comprovar si el formulari és vàlid es redueix llavors a una línia:

const errors = validarReserva(dades, bicicletes);
const esValid = Object.keys(errors).length === 0;

  1. Quan validar i el concepte de camp «tocat»

Validar és fàcil; validar en el moment adequat és el que distingeix un formulari agradable d'un insuportable.

Imagina un camp de correu amb validació a cada tecla. En escriure la primera lletra, «a», apareix en vermell: «El correu no és vàlid». És clar que no ho és: encara falten quinze caràcters. El missatge és correcte i l'experiència, pèssima.

Moment Avantatge Inconvenient Recomanat per a
En escriure (onChange) Reacció immediata Crida abans d'hora en camps buits o a mig omplir Contrasenyes amb requisits, comptadors, camps ja corregits
En perdre el focus (onBlur) La persona ha acabat amb aquest camp L'error triga a aparèixer El moment per defecte
En enviar (onSubmit) Mai molesta durant l'emplenament Tots els errors apareixen de cop al final Última xarxa de seguretat, sempre obligatòria

L'estratègia que fan servir les aplicacions ben fetes en combina les tres:

  1. En perdre el focus, es marca el camp com a «tocat» i es mostra el seu error si en té.
  2. En escriure, només s'actualitza l'error dels camps ja tocats. Així, quan algú està corregint un error, veu desaparèixer el missatge tan bon punt l'arregla.
  3. En enviar, es marquen tots els camps com a tocats i es mostren tots els errors pendents.

Els camps «tocats»

Un camp està tocat quan la persona hi ha interactuat i l'ha abandonat. Es guarda en un segon estat, paral·lel a les dades:

const [tocats, setTocats] = useState({});   // { bicicletaId: true, hores: true, … }

function gestionarBlur(esdeveniment) {
  const { name } = esdeveniment.target;
  setTocats((anterior) => ({ ...anterior, [name]: true }));
}

I la regla per mostrar un missatge és la conjunció de dues condicions:

{tocats.hores && errors.hores && <p className="error">{errors.hores}</p>}

En enviar, es marquen tots de cop:

function marcarTotsTocats(dades) {
  const tots = {};
  Object.keys(dades).forEach((camp) => {
    tots[camp] = true;
  });
  return tots;
}
flowchart TD
    A["La persona escriu"] --> B["S'actualitzen les dades"]
    B --> C["Es recalculen els errors<br/>(valor derivat)"]
    C --> D{"El camp està tocat?"}
    D -- "No" --> E["No es mostra res encara"]
    D -- "Sí" --> F["Es mostra el missatge del camp"]
    G["La persona surt del camp (blur)"] --> H["El camp passa a tocat"] --> D
    I["Enviament del formulari"] --> J["Tots els camps passen a tocats"] --> D

Un punt conceptual important: els errors no són estat. Es calculen amb validarReserva(dades, bicicletes) a cada render a partir de les dades. Guardar-los en un useState crearia una segona font de veritat que caldria recordar d'actualitzar a cada canvi. El que sí que és estat són les dades i els camps tocats, perquè no es poden derivar de res. És la distinció de la lliçó 02-04 aplicada a la validació.

  1. Mostrar els errors i bloquejar l'enviament

Tres decisions de disseny que convé prendre conscientment.

On va el missatge

Al costat del camp que el provoca, mai en una llista al principi ni al final. Qui llegeix el missatge necessita saber de seguida quin camp ha de corregir, sense haver de buscar.

<div className={estils.camp}>
  <label htmlFor="hores">Durada (hores)</label>
  <input id="hores" name="hores" type="number" value={dades.hores} onChange={gestionarCanvi} onBlur={gestionarBlur} />
  {mostrarError('hores') && <p className={estils.error}>{errors.hores}</p>}
</div>

Deshabilitar el botó d'enviament?

És temptador posar disabled={!esValid}, i cal pensar-s'ho bé:

Enfocament A favor En contra
Botó deshabilitat Impedeix l'enviament no vàlid de manera evident No explica per què; qui el veu no sap què li falta. Els botons deshabilitats són problemàtics amb els lectors de pantalla
Botó actiu + validar en enviar En prémer apareixen tots els errors explicats Permet un intent fallit
Enfocament mixt Botó actiu, i en enviar es marquen tots els camps i es mostren els errors És el recomanat

La recomanació pràctica: deixa el botó actiu i valida en l'enviament mostrant tots els missatges. Si el teu disseny exigeix deshabilitar-lo, afegeix sempre un text visible que expliqui què falta.

Estils i senyals

Un camp amb error s'ha de distingir per més d'un senyal: vora de color, icona i el text del missatge. El color tot sol no n'hi ha prou —una part de la població no distingeix el vermell del verd— i aquesta idea, juntament amb la forma correcta d'associar el missatge al camp perquè un lector de pantalla l'anunciï (aria-invalid, aria-describedby, role="alert"), es desenvolupa a la propera lliçó, Accessibilitat en Components Interactius. Aquí ens ocupem de la lògica; allà, que arribi a tothom.

  1. CicloUrbano: FormulariReserva validat

Ho ajuntem tot sobre el formulari de la lliçó anterior.

// src/components/FormulariReserva.jsx
import { useState } from 'react';
import { classes } from '../utilitats/classes.js';
import { validarReserva } from '../utilitats/validarReserva.js';
import estils from './FormulariReserva.module.css';

const DADES_INICIALS = {
  bicicletaId: '',
  dataInici: '',
  hores: 2,
  condicions: false
};

/**
 * Formulari de creació de reserves de CicloUrbano, amb validació.
 * Props:
 *  - bicicletes (array, opcional, per defecte []): catàleg complet
 *  - usuariId (cadena, opcional, per defecte 'usr-01')
 *  - alCrearReserva (funció, opcional): rep l'objecte Reserva validat
 *
 * Estat: `dades` (el que s'escriu) i `tocats` (amb quins camps s'ha interactuat).
 * Els ERRORS no són estat: es deriven de `dades` a cada render.
 */
function FormulariReserva({ bicicletes = [], usuariId = 'usr-01', alCrearReserva }) {
  const [dades, setDades] = useState(DADES_INICIALS);
  const [tocats, setTocats] = useState({});

  // Valors derivats
  const errors = validarReserva(dades, bicicletes);
  const esValid = Object.keys(errors).length === 0;
  const disponibles = bicicletes.filter((bicicleta) => bicicleta.estat === 'disponible');
  const escollida = bicicletes.find((bicicleta) => bicicleta.id === dades.bicicletaId);
  const total = escollida ? escollida.preuHora * Number(dades.hores || 0) : 0;

  // Un error només s'ensenya si el camp ja ha estat tocat
  function mostrarError(camp) {
    return Boolean(tocats[camp] && errors[camp]);
  }

  function gestionarCanvi(esdeveniment) {
    const { name, type, value, checked } = esdeveniment.target;

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

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

  function gestionarBlur(esdeveniment) {
    const { name } = esdeveniment.target;
    setTocats((anterior) => ({ ...anterior, [name]: true }));
  }

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();

    // En enviar, tots els camps passen a tocats: es veuen tots els errors
    const totsTocats = {};
    Object.keys(DADES_INICIALS).forEach((camp) => {
      totsTocats[camp] = true;
    });
    setTocats(totsTocats);

    if (!esValid) {
      return;   // no s'envia res mentre hi hagi errors
    }

    const reserva = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: dades.bicicletaId,
      usuari: usuariId,
      dataInici: dades.dataInici,
      hores: Number(dades.hores),
      estat: 'activa'
    };

    if (alCrearReserva) {
      alCrearReserva(reserva);
    }

    setDades(DADES_INICIALS);
    setTocats({});
  }

  return (
    // noValidate: desactivem els globus del navegador i fem servir els nostres missatges
    <form className={estils.formulari} onSubmit={gestionarEnviament} noValidate>
      <h2>Nova reserva</h2>

      <div className={estils.camp}>
        <label htmlFor="bicicletaId">Bicicleta</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          required
          value={dades.bicicletaId}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
          className={classes(estils.control, mostrarError('bicicletaId') && estils.invalid)}
        >
          <option value="">— Tria una bicicleta —</option>
          {disponibles.map((bicicleta) => (
            <option key={bicicleta.id} value={bicicleta.id}>
              {bicicleta.model} · {bicicleta.preuHora.toFixed(2).replace('.', ',')} €/h
            </option>
          ))}
        </select>
        {mostrarError('bicicletaId') && (
          <p className={estils.error}>{errors.bicicletaId}</p>
        )}
      </div>

      <div className={estils.camp}>
        <label htmlFor="dataInici">Inici de la reserva</label>
        <input
          id="dataInici"
          name="dataInici"
          type="datetime-local"
          required
          value={dades.dataInici}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
          className={classes(estils.control, mostrarError('dataInici') && estils.invalid)}
        />
        {mostrarError('dataInici') && (
          <p className={estils.error}>{errors.dataInici}</p>
        )}
      </div>

      <div className={estils.camp}>
        <label htmlFor="hores">Durada (hores)</label>
        <input
          id="hores"
          name="hores"
          type="number"
          min={1}
          max={24}
          step={1}
          required
          value={dades.hores}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
          className={classes(estils.control, mostrarError('hores') && estils.invalid)}
        />
        {mostrarError('hores') && <p className={estils.error}>{errors.hores}</p>}
      </div>

      <div className={estils.campCasella}>
        <label>
          <input
            name="condicions"
            type="checkbox"
            checked={dades.condicions}
            onChange={gestionarCanvi}
            onBlur={gestionarBlur}
          />{' '}
          Accepto les condicions d'ús de CicloUrbano
        </label>
        {mostrarError('condicions') && (
          <p className={estils.error}>{errors.condicions}</p>
        )}
      </div>

      {escollida && esValid && (
        <p className={estils.total}>
          {escollida.model} · {dades.hores} h ·{' '}
          <strong>{total.toFixed(2).replace('.', ',')} €</strong>
        </p>
      )}

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

export default FormulariReserva;

Comportament resultant, pas a pas:

Acció Què es veu
S'obre el formulari Cap error: encara no hi ha res tocat
S'obre el desplegable i es tanca sense triar En perdre el focus: «Tria una bicicleta.»
Es tria una bicicleta El missatge desapareix tan bon punt la dada és vàlida
S'escriu 0 a les hores i se surt del camp «La reserva mínima és d'1 hora.»
Es corregeix a 2 El missatge desapareix en escriure, perquè el camp ja estava tocat
Es prem «Crear reserva» amb tot buit Apareixen els quatre missatges alhora i no s'envia res
S'envia correctament El pare rep la Reserva; el formulari i els tocats es reinicien

I un detall que il·lustra el valor de les regles de negoci: el desplegable només llista bicicletes disponible, però validarReserva ho torna a comprovar. Si mentre s'emplena el formulari aquesta bicicleta passés a estar llogada, el missatge seria «Elèctrica Pro no està disponible ara mateix». Cap atribut HTML pot fer això.

  1. Biblioteques de formularis i esquemes

En projectes amb molts formularis, el codi d'aquesta lliçó es repeteix. L'ecosistema té dues famílies d'eines que convé conèixer de nom:

Eina Què aporta
React Hook Form Gestiona valors, tocats i errors amb camps majoritàriament no controlats, reduint molt els renders i el codi repetitiu
Formik L'opció clàssica, basada en components controlats; molt estesa en codi existent
Zod Defineix un esquema de dades i hi valida en contra, generant els missatges; s'integra amb les anteriors
Yup Alternativa a Zod, anterior i també molt usada

No les desenvoluparem: totes es recolzen en els conceptes que acabes d'aprendre —controlat i no controlat, camps tocats, errors per camp, validació en l'enviament— i adoptar-les sense entendre el mecanisme porta a no saber depurar-les quan alguna cosa falla. Quan arribis a un projecte que faci servir React Hook Form amb Zod, reconeixeràs cada peça.

Errors Comuns i Consells

  • Posar value en un camp no controlat. El converteix en controlat i el congela. Per a valors inicials, defaultValue / defaultChecked.
  • Oblidar l'atribut name en un formulari no controlat. FormData ignora els camps sense name: el valor no apareix i no hi ha cap error.
  • Esperar que FormData.get() retorni nombres o booleans. Sempre retorna cadena o null. Converteix explícitament, i recorda que una casella sense marcar no apareix.
  • Posar value en un <input type="file">. És impossible per seguretat; el valor es llegeix de esdeveniment.target.files.
  • Guardar els errors a l'estat. Són un valor derivat de les dades. Guardar-los crea una segona font de veritat que es desincronitza.
  • Validar a cada tecla des del primer caràcter. Molesta moltíssim. Mostra l'error només quan el camp estigui «tocat».
  • Validar únicament en escriure i no en enviar. Qui no toca un camp mai no veu el seu error. L'enviament ha de marcar tots els camps com a tocats.
  • Confiar només en la validació del client. Es pot esquivar des de les eines del navegador. El servidor ha de validar sempre.
  • Deshabilitar el botó sense explicar per què. Deixa la persona sense pistes de què li falta. Prefereix validar en enviar i mostrar els missatges.
  • Fer servir pattern amb expressions regulars complicades per al correu. Cap n'és completament correcta. Fes servir type="email" i confirma amb un enviament real.
  • Posar el missatge d'error lluny del camp. Ha d'anar immediatament a sota o al costat.
  • Barrejar la validació nativa i la pròpia sense noValidate. El navegador bloqueja l'enviament abans que s'executi el teu onSubmit i els teus missatges no arriben a mostrar-se mai.
  • Consell: escriu la validació com una funció pura a src/utilitats/. Es prova sense React, es reutilitza i documenta les regles de negoci en un sol lloc.
  • Consell: un missatge per camp i en to útil. «La reserva mínima és d'1 hora» explica què cal fer; «Valor no vàlid» no.
  • Consell: mantén required, min i max al marcatge encara que facis servir noValidate. Comuniquen informació a les tecnologies d'assistència i documenten el camp.

Exercicis

Exercici 1

Aquest formulari barreja els dos enfocaments i té quatre problemes. Identifica'ls, explica el símptoma de cadascun i decideix si convé fer-lo controlat o no controlat, justificant l'elecció.

function FormulariDevolucio({ estacions }) {
  const [estacio, setEstacio] = useState('');

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    const dades = new FormData(esdeveniment.target);
    console.log({
      estacio,
      quilometres: dades.get('quilometres'),
      incidencia: dades.get('incidencia'),
      foto: dades.get('foto')
    });
    esdeveniment.target.reset();
  }

  return (
    <form onSubmit={gestionarEnviament}>
      <select value={estacio}>
        {estacions.map((e) => <option key={e.id} value={e.id}>{e.nom}</option>)}
      </select>

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

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

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

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

Exercici 2

Escriu la funció pura validarEstacio(dades) a src/utilitats/validarEstacio.js, que validi l'alta d'una estació de CicloUrbano. Ha de retornar un objecte d'errors per camp amb aquestes regles:

  • nom: obligatori, entre 3 i 40 caràcters, i no pot coincidir (ignorant majúscules i espais sobrants) amb el nom d'una estació existent, que arribarà com a segon paràmetre.
  • barri: obligatori, i ha de ser un de 'Centre', 'Nord' o 'Eixample'.
  • places: enter entre 5 i 60.
  • enServei: si és false, hi ha d'haver un motiu d'almenys 10 caràcters.

Escriu a més tres casos de prova a mà (entrada i sortida esperada) que demostrin que funciona.

Exercici 3

Converteix el FormulariIncidencia de l'apartat 4 —que és no controlat— en un formulari controlat amb validació, aplicant el patró complet de camps tocats. Les regles:

  • bicicletaId: obligatòria.
  • descripcio: obligatòria, mínim 15 caràcters, màxim 500, amb comptador de caràcters restants.
  • urgencia: enter entre 1 i 5.
  • Si bloquejaUs està marcat, la urgència ha de ser 4 o 5.

Explica per què aquesta última regla és impossible d'expressar amb validació nativa del navegador.

Solucions

Solució 1.

Els quatre problemes:

Problema Símptoma
El <select>value però no onChange El desplegable queda congelat en l'opció inicial i React avisa de camp de només lectura
El camp numèric no té name FormData no el recull: dades.get('quilometres') retorna null sempre
<input type="file" value=""> Prohibit per seguretat; React avisa. A més, FormData.get('foto') retorna un objecte File, no una cadena
esdeveniment.target.reset() amb un camp controlat El select no es reinicia: React torna a posar el valor de l'estat al render següent

Sobre l'enfocament: convé fer-ho tot no controlat llevat que calgui reaccionar al que s'escriu. És un part de devolució que es llegeix sencer en enviar, no té camps interdependents ni validació en temps real, i inclou un camp de fitxer, que obligatòriament és no controlat. Barrejar els dos enfocaments en el mateix formulari és el que ha produït tres de les quatre fallades.

// src/components/FormulariDevolucio.jsx

/**
 * Part de devolució d'una bicicleta. Formulari NO controlat:
 * els valors els guarda el DOM i es llegeixen amb FormData en enviar.
 * Props:
 *  - estacions (array, opcional, per defecte [])
 *  - alRetornar (funció, opcional): rep el part de devolució
 */
function FormulariDevolucio({ estacions = [], alRetornar }) {
  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();

    const formulari = esdeveniment.target;
    const dades = new FormData(formulari);

    const part = {
      estacioId: dades.get('estacioId'),
      quilometres: Number(dades.get('quilometres')),
      // Una casella sense marcar no apareix a FormData
      incidencia: dades.get('incidencia') === 'on',
      foto: dades.get('foto')   // objecte File, o un File buit si no s'ha triat res
    };

    if (alRetornar) {
      alRetornar(part);
    }

    formulari.reset();   // correcte: tots els camps són no controlats
  }

  return (
    <form onSubmit={gestionarEnviament}>
      <label htmlFor="estacioId">Estació de devolució</label>
      <select id="estacioId" name="estacioId" defaultValue="" required>
        <option value="">— Tria una estació —</option>
        {estacions.map((estacio) => (
          <option key={estacio.id} value={estacio.id}>
            {estacio.nom} ({estacio.barri})
          </option>
        ))}
      </select>

      <label htmlFor="quilometres">Quilòmetres recorreguts</label>
      <input id="quilometres" name="quilometres" type="number" min={0} defaultValue={0} />

      <label>
        <input name="incidencia" type="checkbox" /> Hi ha una incidència a reportar
      </label>

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

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

export default FormulariDevolucio;

Solució 2.

// src/utilitats/validarEstacio.js

const BARRIS_VALIDS = ['Centre', 'Nord', 'Eixample'];
const MIN_PLACES = 5;
const MAX_PLACES = 60;

function normalitzar(text) {
  return String(text ?? '').trim().toLowerCase();
}

/**
 * Valida l'alta d'una estació de CicloUrbano.
 *
 * @param {Object} dades - { nom, barri, places, enServei, motiu }
 * @param {Array} existents - estacions ja registrades, per evitar duplicats
 * @returns {Object} un missatge per camp amb error; {} si tot és vàlid
 */
export function validarEstacio(dades, existents = []) {
  const errors = {};

  // --- Nom ---
  const nom = String(dades.nom ?? '').trim();

  if (nom === '') {
    errors.nom = 'El nom de l\'estació és obligatori.';
  } else if (nom.length < 3) {
    errors.nom = 'El nom ha de tenir com a mínim 3 caràcters.';
  } else if (nom.length > 40) {
    errors.nom = 'El nom no pot superar els 40 caràcters.';
  } else if (existents.some((estacio) => normalitzar(estacio.nom) === normalitzar(nom))) {
    errors.nom = `Ja existeix una estació anomenada «${nom}».`;
  }

  // --- Barri ---
  if (!dades.barri) {
    errors.barri = 'Tria un barri.';
  } else if (!BARRIS_VALIDS.includes(dades.barri)) {
    errors.barri = `El barri ha de ser un d'aquests: ${BARRIS_VALIDS.join(', ')}.`;
  }

  // --- Places ---
  const places = Number(dades.places);

  if (dades.places === '' || Number.isNaN(places)) {
    errors.places = 'Indica el nombre de places.';
  } else if (!Number.isInteger(places)) {
    errors.places = 'Les places han de ser un nombre enter.';
  } else if (places < MIN_PLACES || places > MAX_PLACES) {
    errors.places = `Les places han d'estar entre ${MIN_PLACES} i ${MAX_PLACES}.`;
  }

  // --- Motiu, només si l'estació no entra en servei ---
  if (dades.enServei === false) {
    const motiu = String(dades.motiu ?? '').trim();

    if (motiu.length < 10) {
      errors.motiu = 'Explica en almenys 10 caràcters per què no entra en servei.';
    }
  }

  return errors;
}

Els tres casos de prova:

const existents = [
  { id: 'est-01', nom: 'Plaça Major', barri: 'Centre', places: 20 },
  { id: 'est-02', nom: 'Parc Nord', barri: 'Nord', places: 15 }
];

// Cas 1: tot correcte -> {}
validarEstacio(
  { nom: 'Mercat Vell', barri: 'Eixample', places: 25, enServei: true },
  existents
);

// Cas 2: nom duplicat amb caixa diferent i espais, places fora de rang
validarEstacio(
  { nom: '  plaça major  ', barri: 'Centre', places: 100, enServei: true },
  existents
);
// -> {
//      nom: 'Ja existeix una estació anomenada «plaça major».',
//      places: 'Les places han d\'estar entre 5 i 60.'
//    }

// Cas 3: fora de servei sense motiu suficient
validarEstacio(
  { nom: 'Pont Sud', barri: 'Eixample', places: 12, enServei: false, motiu: 'obres' },
  existents
);
// -> { motiu: 'Explica en almenys 10 caràcters per què no entra en servei.' }

La funció normalitzar centralitza la comparació —retallar espais i passar a minúscules— perquè la regla s'apliqui igual a tot arreu. I retornar {} quan tot és vàlid permet la comprovació d'una línia: Object.keys(errors).length === 0.

Solució 3.

// src/components/FormulariIncidencia.jsx
import { useState } from 'react';

const MAX_DESCRIPCIO = 500;
const MIN_DESCRIPCIO = 15;

const DADES_INICIALS = {
  bicicletaId: '',
  descripcio: '',
  urgencia: 3,
  bloquejaUs: false
};

function validarIncidencia(dades) {
  const errors = {};

  if (!dades.bicicletaId) {
    errors.bicicletaId = 'Tria la bicicleta afectada.';
  }

  const descripcio = dades.descripcio.trim();
  if (descripcio.length === 0) {
    errors.descripcio = 'Descriu la incidència.';
  } else if (descripcio.length < MIN_DESCRIPCIO) {
    errors.descripcio = `Descriu la incidència amb almenys ${MIN_DESCRIPCIO} caràcters.`;
  } else if (descripcio.length > MAX_DESCRIPCIO) {
    errors.descripcio = `La descripció no pot superar els ${MAX_DESCRIPCIO} caràcters.`;
  }

  const urgencia = Number(dades.urgencia);
  if (dades.urgencia === '' || Number.isNaN(urgencia)) {
    errors.urgencia = 'Indica el nivell d\'urgència.';
  } else if (!Number.isInteger(urgencia) || urgencia < 1 || urgencia > 5) {
    errors.urgencia = 'La urgència ha de ser un enter entre 1 i 5.';
  } else if (dades.bloquejaUs && urgencia < 4) {
    // Validació CREUADA entre dos camps
    errors.urgencia = 'Si la incidència impedeix l\'ús, la urgència ha de ser 4 o 5.';
  }

  return errors;
}

/**
 * Part d'incidència d'una bicicleta, controlat i validat.
 * Props:
 *  - bicicletes (array, opcional, per defecte [])
 *  - alRegistrar (funció, opcional): rep la incidència validada
 */
function FormulariIncidencia({ bicicletes = [], alRegistrar }) {
  const [dades, setDades] = useState(DADES_INICIALS);
  const [tocats, setTocats] = useState({});

  const errors = validarIncidencia(dades);
  const esValid = Object.keys(errors).length === 0;
  const restants = MAX_DESCRIPCIO - dades.descripcio.length;

  function mostrarError(camp) {
    return Boolean(tocats[camp] && errors[camp]);
  }

  function gestionarCanvi(esdeveniment) {
    const { name, type, value, checked } = esdeveniment.target;

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

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

  function gestionarBlur(esdeveniment) {
    const { name } = esdeveniment.target;
    setTocats((anterior) => ({ ...anterior, [name]: true }));
  }

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();

    const tots = {};
    Object.keys(DADES_INICIALS).forEach((camp) => {
      tots[camp] = true;
    });
    setTocats(tots);

    if (!esValid) {
      return;
    }

    if (alRegistrar) {
      alRegistrar({ ...dades, descripcio: dades.descripcio.trim() });
    }

    setDades(DADES_INICIALS);
    setTocats({});
  }

  return (
    <form onSubmit={gestionarEnviament} noValidate>
      <div>
        <label htmlFor="bicicletaId">Bicicleta</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          value={dades.bicicletaId}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
        >
          <option value="">— Tria una bicicleta —</option>
          {bicicletes.map((bicicleta) => (
            <option key={bicicleta.id} value={bicicleta.id}>
              {bicicleta.model} ({bicicleta.id})
            </option>
          ))}
        </select>
        {mostrarError('bicicletaId') && <p className="error">{errors.bicicletaId}</p>}
      </div>

      <div>
        <label htmlFor="descripcio">Descripció</label>
        <textarea
          id="descripcio"
          name="descripcio"
          rows={3}
          maxLength={MAX_DESCRIPCIO}
          value={dades.descripcio}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
        />
        <small>Queden {restants} caràcters.</small>
        {mostrarError('descripcio') && <p className="error">{errors.descripcio}</p>}
      </div>

      <div>
        <label htmlFor="urgencia">Urgència (1-5)</label>
        <input
          id="urgencia"
          name="urgencia"
          type="number"
          min={1}
          max={5}
          value={dades.urgencia}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
        />
        {mostrarError('urgencia') && <p className="error">{errors.urgencia}</p>}
      </div>

      <label>
        <input
          name="bloquejaUs"
          type="checkbox"
          checked={dades.bloquejaUs}
          onChange={gestionarCanvi}
          onBlur={gestionarBlur}
        />{' '}
        Impedeix l'ús de la bicicleta
      </label>

      <button type="submit">Registrar incidència</button>
    </form>
  );
}

export default FormulariIncidencia;

Per què l'última regla és impossible de forma nativa: els atributs de validació d'HTML són locals a un camp. min={1} i max={5} només saben del valor d'aquest <input>; no existeix cap atribut que digui «el mínim d'aquest camp depèn de si aquella casella està marcada». És una validació creuada entre dos camps, és a dir, una regla de negoci, i per a això cal JavaScript. Fixa't a més que l'enfocament de funció pura l'absorbeix sense esforç: és un else if més dins de la validació d'urgencia.

Conclusió

Amb aquesta lliçó els formularis de React queden complets. Has vist l'alternativa als components controlats: els no controlats, en què el valor el guarda el DOM, es declara amb defaultValue o defaultChecked i es llegeix en enviar amb FormData —recordant que retorna cadenes, que les caselles sense marcar no apareixen i que només recull camps amb name— o amb useRef, la via d'accés directe al node que s'estudiarà a fons al Mòdul 5. Saps triar entre tots dos enfocaments amb criteri: controlat sempre que necessitis reaccionar al que s'escriu, no controlat per a formularis grans que només es llegeixen al final, i obligatòriament no controlat a l'<input type="file">, que per raons de seguretat no admet que el codi en fixi el valor.

Pel que fa a la validació, has après que el navegador ofereix gratis un primer filtre amb required, min, max, type="email" i pattern, però que els seus missatges no es poden controlar, només mostra un error alhora, no expressa regles de negoci ni validacions creuades i es pot esquivar —d'aquí que el servidor hagi de validar sempre—. Per això la validació seriosa s'escriu com una funció pura a src/utilitats/, que rep les dades i retorna un objecte amb un missatge per camp: es prova sense React, es reutilitza i concentra les regles del domini en un sol lloc. I sobretot has après quan validar: els errors es calculen sempre, però només es mostren quan el camp està tocat, amb l'enviament marcant tots els camps de cop. Els errors no són estat; les dades i els tocats sí.

CicloUrbano té ara un FormulariReserva que no deixa crear reserves en el passat, de zero hores, sobre bicicletes no disponibles ni sense acceptar les condicions, i que diu exactament què falla i on. Queda una pregunta pendent: aquests missatges es veuen, però se senten? Un lector de pantalla no relaciona automàticament un paràgraf vermell amb el camp de dalt, un <article> amb onClick no es pot activar amb el teclat i una etiqueta d'estat que només es distingeix pel color deixa fora qui no percep aquest color. Tot això —i com comprovar-ho— és el que tanca el mòdul a Accessibilitat en Components Interactius.

Curs de React

Mòdul 1: Introducció a React

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats