El catàleg de CicloUrbano ja es pinta a partir de les dades, reacciona als clics i s'adapta a l'estat de cada bicicleta. Però continua sent una via d'un sol sentit: l'aplicació mostra informació i no en recull cap. Per crear una reserva cal triar una bicicleta, indicar quan comença, quantes hores dura i acceptar les condicions, i això significa formularis. A React els formularis tenen una particularitat que desconcerta al principi: per defecte, un <input> guarda el seu propi valor dins del DOM, al marge de React, la qual cosa xoca de ple amb la idea que la interfície és una funció de l'estat. La solució es diu component controlat, i en aquesta lliçó la dominaràs camp per camp —text, àrea de text, desplegable, caselles, radis, números i dates—, aprendràs a gestionar un formulari sencer amb un sol estat i un sol gestor, i construiràs el FormulariReserva de CicloUrbano de principi a fi.

Contingut

  1. El conflicte: dues fonts de veritat
  2. Què és un component controlat
  3. value + onChange pas a pas
  4. Un value sense onChange: el camp congelat
  5. Camps de text i textarea
  6. El desplegable select, simple i múltiple
  7. Caselles de verificació amb checked
  8. Grups de botons de ràdio
  9. number i date: la conversió de tipus
  10. Un sol estat per a tot el formulari
  11. L'enviament: onSubmit i preventDefault
  12. CicloUrbano: FormulariReserva

  1. El conflicte: dues fonts de veritat

En HTML, els camps de formulari són elements amb memòria pròpia. Quan escrius en un <input>, el navegador guarda aquest text dins del node del DOM i ningú més se n'assabenta:

<input type="text" id="model" value="Urbana" />
<!-- En escriure, el valor canvia dins del DOM. Per llegir-lo: -->
<script>
  const text = document.getElementById('model').value;
</script>

Aquest comportament és còmode en una pàgina estàtica i problemàtic a React, perquè crea dos llocs on viu la mateixa informació:

Font Què sap Problema
El DOM El que ha escrit la persona usuària React no s'assabenta del canvi
L'estat de React El que React creu que hi ha Pot quedar desfasat del DOM

Amb dues fonts de veritat apareixen preguntes sense resposta clara: com habilites el botó «Enviar» només quan el camp té contingut, si React no sap què hi ha a dins? Com poses el text en majúscules mentre s'escriu? Com buides el formulari després d'enviar-lo?

La resposta de React és taxativa: que només hi hagi una font de veritat, i que sigui l'estat.

  1. Què és un component controlat

Un component controlat és un camp de formulari el valor del qual el dicta l'estat de React. El camp no decideix res: només mostra el que l'estat li diu i avisa dels intents de canvi.

import { useState } from 'react';

function CercadorModel() {
  const [text, setText] = useState('');

  return (
    <input
      type="text"
      value={text}                                        // l'estat mana
      onChange={(esdeveniment) => setText(esdeveniment.target.value)}   // el camp avisa
    />
  );
}

El cicle complet, que convé entendre de debò perquè és contraintuïtiu la primera vegada:

flowchart TD
    A["La persona prem una tecla"] --> B["Es dispara onChange"]
    B --> C["El gestor llegeix esdeveniment.target.value"]
    C --> D["setText(nouValor)<br/>actualitza l'estat"]
    D --> E["React torna a renderitzar"]
    E --> F["L'input rep value = nou estat"]
    F --> G["Es veu la lletra en pantalla"]

El que sorprèn és el pas final: la lletra no apareix perquè l'escriguis, sinó perquè l'estat ha canviat. Si el gestor no actualitzés l'estat, la tecla no deixaria cap rastre per molt que la premis. El camp és una finestra a l'estat, no un magatzem.

Això és exactament interfície = f(estat) aplicat als formularis, i d'aquí surten tots els avantatges:

Avantatge Exemple concret
Un sol lloc on mirar El valor és a l'estat; no cal consultar el DOM
Validació en temps real Es pot comprovar a cada tecla (lliçó 03-05)
Transformar mentre s'escriu Posar en majúscules, limitar caràcters, formatar
Interfície reactiva Deshabilitar el botó, mostrar un comptador de caràcters, previsualitzar
Reiniciar és trivial setDades(valorsInicials) i el formulari es buida sol
Fàcil de provar Canviar l'estat equival a omplir el formulari

Existeix l'alternativa —deixar que el DOM guardi el valor i llegir-lo només al final—, i es diu component no controlat. Té els seus casos d'ús legítims i es tracta a la propera lliçó, Validació de Formularis i Components No Controlats. En el 90 % dels formularis d'una aplicació React, la resposta correcta és «controlat».

  1. value + onChange pas a pas

Els dos ingredients són inseparables. Analitzem-los per separat.

value: l'estat mana

<input type="text" value={text} />

Li diu al camp: «mostris el que mostris, el teu contingut és aquest». A cada render React comprova el valor del DOM i, si no coincideix amb la prop, el corregeix.

onChange: el camp avisa

onChange={(esdeveniment) => setText(esdeveniment.target.value)}

Tres detalls importants d'aquesta línia:

  • esdeveniment.target és el node del DOM que ha originat l'esdeveniment: el <input>. Tal com vas aprendre a 03-01, és diferent de currentTarget, encara que en un camp solt coincideixin.
  • .value és sempre una cadena de text, fins i tot en un <input type="number">. Hi tornarem a l'apartat 9.
  • onChange a React es dispara a cada pulsació de tecla, a diferència de l'esdeveniment change natiu del DOM, que només salta en perdre el focus. Aquesta diferència és la que fa possible tot aquest patró.

El gestor amb nom

Per a qualsevol cosa que no sigui trivial, extreu el gestor seguint la convenció de 03-01:

import { useState } from 'react';

function CercadorModel({ alBuscar }) {
  const [text, setText] = useState('');

  function gestionarCanviText(esdeveniment) {
    const valor = esdeveniment.target.value;
    setText(valor);

    if (alBuscar) {
      alBuscar(valor);   // avisem el pare a cada pulsació
    }
  }

  return (
    <label>
      Buscar model:{' '}
      <input
        type="text"
        value={text}
        onChange={gestionarCanviText}
        placeholder="Urbana, Elèctrica…"
      />
    </label>
  );
}

export default CercadorModel;

Fixa't que aquí sí que passem la referència (onChange={gestionarCanviText}), sense fletxa, perquè no calen arguments extra: React ja passa l'esdeveniment com a primer paràmetre.

Transformar mentre s'escriu

Com que el valor passa pel teu codi abans de tornar a la pantalla, el pots modificar:

function gestionarCanviCodi(esdeveniment) {
  // Només majúscules i com a màxim 8 caràcters
  const valor = esdeveniment.target.value.toUpperCase().slice(0, 8);
  setCodi(valor);
}

Això és impossible amb un camp no controlat sense manipular el DOM a mà. És una de les raons de pes per preferir els controlats.

  1. Un value sense onChange: el camp congelat

Aquest és l'error número u amb formularis a React, i l'avís que mostra React és dels pocs que la gent llegeix sencer perquè el símptoma és alarmant.

// ✘ MALAMENT: value fix sense gestor
<input type="text" value={text} />

Símptoma: el camp no admet escriptura. Prems tecles i no passa res, com si estigués bloquejat.

Causa: el value està lligat a l'estat, i sense onChange l'estat mai canvia. A cada intent d'escriptura React reimposa el valor anterior.

Avís a la consola:

Warning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

El mateix missatge enumera les tres sortides vàlides:

Intenció Solució
El camp ha de ser editable Afegir onChange que actualitzi l'estat
El camp ha de ser de només lectura de forma intencionada Afegir readOnly al costat del value
El camp ha de tenir un valor inicial però gestionar-se pel DOM Fer servir defaultValue en lloc de value (camp no controlat, lliçó 03-05)
{/* Només lectura intencionada: l'avís desapareix */}
<input type="text" value={bicicleta.id} readOnly />

Hi ha un segon avís, germà de l'anterior, que apareix quan l'estat inicial és undefined o null:

Warning: A component is changing an uncontrolled input to be controlled.

Passa així: al primer render value={undefined} fa que React tracti el camp com a no controlat; tan bon punt l'estat rep una cadena, passa a ser controlat, i React avisa del canvi de règim. La solució és sempre la mateixa: inicialitza l'estat amb una cadena buida, mai amb undefined ni amb null.

const [text, setText] = useState('');        // ✔ correcte
const [text, setText] = useState();          // ✘ provoca l'avís
const [text, setText] = useState(null);      // ✘ provoca l'avís

  1. Camps de text i textarea

Els camps d'una línia funcionen tots igual, canviï el type que canviï:

<input type="text" value={nom} onChange={gestionarCanvi} />
<input type="email" value={email} onChange={gestionarCanvi} />
<input type="password" value={contrasenya} onChange={gestionarCanvi} />
<input type="search" value={cerca} onChange={gestionarCanvi} />
<input type="tel" value={telefon} onChange={gestionarCanvi} />

El type canvia el teclat al mòbil i la validació nativa del navegador, però el patró de React és idèntic.

El <textarea> sí que té una diferència. En HTML, el seu contingut va entre les etiquetes:

<!-- HTML clàssic -->
<textarea rows="4">Text inicial</textarea>

A React, no: es tracta com un camp més i el valor va a la prop value.

{/* ✔ React: el contingut va a value */}
<textarea rows={4} value={comentari} onChange={gestionarCanviComentari} />

{/* ✘ No facis això: React avisa que facis servir la prop value */}
<textarea rows={4} onChange={gestionarCanviComentari}>{comentari}</textarea>

La raó és la coherència: així tots els camps es llegeixen i s'escriuen igual, sense excepcions per recordar.

Un exemple complet amb comptador de caràcters, que mostra el còmode que resulta tenir el valor a l'estat:

import { useState } from 'react';

const MAXIM = 200;

function NotaOperari() {
  const [nota, setNota] = useState('');
  const restants = MAXIM - nota.length;   // valor derivat, no estat

  return (
    <div>
      <label htmlFor="nota-operari">Nota de l'operari</label>
      <textarea
        id="nota-operari"
        rows={4}
        maxLength={MAXIM}
        value={nota}
        onChange={(esdeveniment) => setNota(esdeveniment.target.value)}
      />
      <p className={restants < 20 ? 'avis' : ''}>
        Queden {restants} caràcters.
      </p>
    </div>
  );
}

export default NotaOperari;

  1. El desplegable select, simple i múltiple

Aquí React se separa clarament de l'HTML. En HTML, l'opció seleccionada es marca amb l'atribut selected a l'<option>:

<!-- HTML clàssic -->
<select>
  <option value="urbana">Urbana</option>
  <option value="electrica" selected>Elèctrica</option>
</select>

A React, el value va al <select> i cap <option> porta selected:

function SelectorEstacio({ estacions, estacioId, alCanviar }) {
  return (
    <label>
      Estació de recollida:{' '}
      <select value={estacioId} onChange={alCanviar}>
        <option value="">— Tria una estació —</option>
        {estacions.map((estacio) => (
          <option key={estacio.id} value={estacio.id}>
            {estacio.nom} ({estacio.barri})
          </option>
        ))}
      </select>
    </label>
  );
}

Punts clau:

  • El value del select ha de coincidir amb el value d'alguna <option>. Si no coincideix amb cap, el navegador mostra la primera i apareix una desincronització difícil de veure.
  • L'opció buida <option value=""> és el truc habitual per representar «res triat encara». Combina bé amb la validació de la propera lliçó.
  • Les opcions generades amb map necessiten key, com qualsevol llista (lliçó 03-03).
  • esdeveniment.target.value sempre és una cadena. Si els teus identificadors fossin numèrics, caldrien conversions; amb els est-01 de CicloUrbano no hi ha problema.

El select múltiple

Amb multiple, el valor deixa de ser una cadena i passa a ser un array, i cal llegir les opcions seleccionades del DOM:

import { useState } from 'react';

const TIPUS = ['urbana', 'electrica', 'carga'];

function FiltreTipusMultiple() {
  const [tipus, setTipus] = useState([]);   // array, no cadena

  function gestionarCanvi(esdeveniment) {
    // esdeveniment.target.selectedOptions és una col·lecció tipus array, no un array real
    const escollits = Array.from(esdeveniment.target.selectedOptions, (opcio) => opcio.value);
    setTipus(escollits);
  }

  return (
    <>
      <select multiple value={tipus} onChange={gestionarCanvi} size={3}>
        {TIPUS.map((tipusOpcio) => (
          <option key={tipusOpcio} value={tipusOpcio}>
            {tipusOpcio}
          </option>
        ))}
      </select>
      <p>Seleccionats: {tipus.length > 0 ? tipus.join(', ') : 'cap'}</p>
    </>
  );
}

export default FiltreTipusMultiple;

Array.from(col·lecció, funció) converteix la col·lecció del DOM en un array real aplicant de pas la transformació, en una sola passada. I recorda el tipus.length > 0 en lloc de tipus.length &&: la trampa del 0 de la lliçó 03-02 continua vigent.

  1. Caselles de verificació amb checked

Una casella no té «text»: té un estat de marcatge. Per això fa servir dues props diferents:

Camp Prop de valor Propietat de l'esdeveniment Tipus de l'estat
text, textarea, select value esdeveniment.target.value cadena
checkbox checked esdeveniment.target.checked booleà
radio checked esdeveniment.target.checked booleà per opció
import { useState } from 'react';

function AcceptacioCondicions() {
  const [acceptat, setAcceptat] = useState(false);

  return (
    <label>
      <input
        type="checkbox"
        checked={acceptat}
        onChange={(esdeveniment) => setAcceptat(esdeveniment.target.checked)}
      />{' '}
      Accepto les condicions d'ús de CicloUrbano
    </label>
  );
}

export default AcceptacioCondicions;

L'error clàssic és escriure setAcceptat(esdeveniment.target.value): en una casella, value val "on" per defecte, que és una cadena sempre truthy. La casella es marcaria i no es podria desmarcar mai.

Un grup de caselles independents

Quan hi ha diverses caselles relacionades, el natural és guardar un objecte o un array:

import { useState } from 'react';

const ESTATS = ['disponible', 'alquilada', 'mantenimiento'];

function FiltreEstats() {
  const [marcats, setMarcats] = useState({
    disponible: true,
    alquilada: false,
    mantenimiento: false
  });

  function gestionarCanvi(esdeveniment) {
    const { name, checked } = esdeveniment.target;
    // Còpia de l'objecte anterior + sobreescriptura d'una sola clau
    setMarcats((anterior) => ({ ...anterior, [name]: checked }));
  }

  return (
    <fieldset>
      <legend>Mostrar bicicletes en estat…</legend>
      {ESTATS.map((estat) => (
        <label key={estat}>
          <input
            type="checkbox"
            name={estat}
            checked={marcats[estat]}
            onChange={gestionarCanvi}
          />{' '}
          {estat}
        </label>
      ))}
    </fieldset>
  );
}

export default FiltreEstats;

Aquí apareix per primera vegada la peça que vertebra l'apartat 10: [name]: checked, un nom de propietat calculat. El detallem allà.

  1. Grups de botons de ràdio

Els ràdios són un cas especial: diversos camps comparteixen un únic valor. El que els agrupa és l'atribut name, que ha de ser idèntic a tots, i cadascun declara quin valor representa.

import { useState } from 'react';

const OPCIONS = [
  { valor: 'urbana', etiqueta: 'Urbana' },
  { valor: 'electrica', etiqueta: 'Elèctrica' },
  { valor: 'carga', etiqueta: 'De càrrega' }
];

function SelectorTipusRadio() {
  const [tipus, setTipus] = useState('urbana');

  return (
    <fieldset>
      <legend>Tipus de bicicleta</legend>

      {OPCIONS.map((opcio) => (
        <label key={opcio.valor}>
          <input
            type="radio"
            name="tipusBicicleta"               // el MATEIX name a tots
            value={opcio.valor}                 // el que representa aquest ràdio
            checked={tipus === opcio.valor}     // és aquest l'escollit?
            onChange={(esdeveniment) => setTipus(esdeveniment.target.value)}
          />{' '}
          {opcio.etiqueta}
        </label>
      ))}
    </fieldset>
  );
}

export default SelectorTipusRadio;

La línia que cal entendre és checked={tipus === opcio.valor}. Cada ràdio es pregunta: «el valor de l'estat sóc jo?». Només un respon que sí, i això garanteix per construcció que no puguin estar marcats dos alhora.

A onChange es llegeix esdeveniment.target.value —no checked—, perquè el que volem guardar és quin s'ha triat, no si està marcat.

Un detall d'accessibilitat que es desenvoluparà a 03-06: agrupar els ràdios en un <fieldset> amb <legend> no és decoratiu. És el que permet a un lector de pantalla anunciar «Tipus de bicicleta, grup, opció 2 de 3».

  1. number i date: la conversió de tipus

Aquí hi ha la trampa silenciosa dels formularis a React: esdeveniment.target.value és SEMPRE una cadena, sense importar el type del camp.

<input type="number" value={hores} onChange={(e) => setHores(e.target.value)} />
// Si escrius 3, l'estat guarda "3" (cadena), no 3 (número)

Les conseqüències apareixen tan bon punt fas comptes:

const hores = '3';           // el que hi ha realment a l'estat
hores * 4.0                  // 12  -> la multiplicació converteix, això funciona
hores + 1                    // "31" -> ✘ la suma concatena cadenes
hores > 24                   // false -> compara "3" amb 24, converteix, funciona per sort

La solució és convertir al gestor, perquè l'estat guardi sempre el tipus correcte:

function gestionarCanviHores(esdeveniment) {
  const valor = esdeveniment.target.value;

  // Camp buit: guardem cadena buida perquè l'input continuï sent controlat
  if (valor === '') {
    setHores('');
    return;
  }

  setHores(Number(valor));
}

Per què aquest cas especial del buit? Perquè Number('') és 0, i si convertíssim sense més, esborrar el camp escriuria un 0 que no es pot esborrar. Guardar la cadena buida manté el camp controlat i permet buidar-lo.

Tipus de camp Què retorna .value Conversió recomanada
text, textarea, select cadena Cap
checkbox fer servir .checked Cap (ja és booleà)
number cadena, o '' si està buit o és invàlid Number(valor), tractant '' a part
range cadena Number(valor)
date cadena "2026-05-04" Cap per guardar; new Date(valor) per calcular
datetime-local cadena "2026-05-04T09:00" Cap: coincideix amb el format del domini.js
time cadena "09:00" Cap
file .files, no .value Sempre no controlat (lliçó 03-05)

Sobre les dates hi ha una bona notícia per a CicloUrbano: el format que produeix <input type="datetime-local"> és exactament "2026-05-04T09:00", el mateix que fa servir el camp dataInici del domini.js. No cal cap conversió per guardar-lo; només la necessitaràs per comparar-lo, i això arriba a la propera lliçó.

Els camps number i date admeten a més atributs que el navegador respecta: min, max i step. Són una ajuda, però no una garantia: es poden esquivar. La validació de debò és tema de 03-05.

  1. Un sol estat per a tot el formulari

Amb quatre camps, tenir quatre useState i quatre gestors comença a ser sorollós:

// Funciona, però no escala
const [bicicletaId, setBicicletaId] = useState('');
const [dataInici, setDataInici] = useState('');
const [hores, setHores] = useState(1);
const [condicions, setCondicions] = useState(false);

L'alternativa idiomàtica és un objecte d'estat i un gestor genèric:

const [dades, setDades] = useState({
  bicicletaId: '',
  dataInici: '',
  hores: 1,
  condicions: false
});

function gestionarCanvi(esdeveniment) {
  const { name, type, value, checked } = esdeveniment.target;
  const valorFinal = type === 'checkbox' ? checked : value;

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

Analitzem les dues línies clau.

El nom de propietat calculat

{ ...anterior, [name]: valorFinal }

Els claudàtors al voltant de name són sintaxi de JavaScript (ES6) anomenada nom de propietat calculat. Significa: «fes servir el contingut de la variable name com a nom de la propietat».

const name = 'hores';

{ [name]: 3 }    // -> { hores: 3 }     ✔ el nom surt de la variable
{ name: 3 }      // -> { name: 3 }      ✘ el nom és literalment "name"

Combinat amb l'spread ...anterior, l'expressió completa significa: «copia tot el que hi havia i substitueix només la propietat el nom de la qual coincideix amb el name del camp». És l'actualització immutable d'objectes que vas aprendre a 02-04, aplicada a formularis.

L'atribut name

Perquè això funcioni, cada camp ha de portar un name que coincideixi amb la seva clau a l'objecte d'estat:

<input name="dataInici" value={dades.dataInici} onChange={gestionarCanvi} />
<input name="hores" type="number" value={dades.hores} onChange={gestionarCanvi} />
<input name="condicions" type="checkbox" checked={dades.condicions} onChange={gestionarCanvi} />

Si el name no coincideix amb cap clau, el gestor afegeix una clau nova a l'objecte en silenci, i el camp que esperaves actualitzar no canvia. És una fallada tan comuna com difícil de veure: revisa sempre els name quan un camp no respongui.

La forma funcional de l'actualitzador

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

Es fa servir la forma funcional —setDades(fn) en lloc de setDades(objecte)— pel que vas aprendre a 02-04: garanteix partir del valor més recent encara que hi hagi diverses actualitzacions seguides. En un formulari amb camps que s'afecten entre si, aquesta garantia evita perdre canvis.

Fixa't també en els parèntesis que embolcallen l'objecte: (anterior) => ({ … }). Sense ells, JavaScript interpretaria les claus com el cos de la funció i retornaria undefined.

Un estat o diversos: com decidir

Situació Recomanació
1 o 2 camps independents Un useState per camp, més llegible
3 o més camps que s'envien junts Un objecte i un gestor genèric
Camps que es validen i es reinicien alhora Un objecte
Un camp amb lògica molt particular El seu propi useState, encara que la resta vagi en objecte
Formularis molt grans amb lògica complexa useReducer, que veuràs a Hook useReducer

  1. L'enviament: onSubmit i preventDefault

El gestor va al <form>, no al botó

{/* ✔ CORRECTE */}
<form onSubmit={gestionarEnviament}>
  <button type="submit">Reservar</button>
</form>

{/* ✘ INCOMPLET: només funciona amb clic de ratolí */}
<form>
  <button type="button" onClick={gestionarEnviament}>Reservar</button>
</form>

La diferència no és d'estil, és funcional. Un <form> amb onSubmit s'envia de tres maneres diferents:

  1. Prement el botó type="submit".
  2. Prement Enter en qualsevol camp de text del formulari.
  3. Mitjançant tecnologies d'assistència que activen l'enviament del formulari.

Amb el gestor al botó només funciona la primera. La segona és la que fa servir moltíssima gent sense pensar-hi, i la seva absència es percep com si l'aplicació estigués trencada.

preventDefault és obligatori

function gestionarEnviament(esdeveniment) {
  esdeveniment.preventDefault();   // sense això, la pàgina es recarrega
  // … processar les dades
}

El comportament natiu d'un formulari és enviar les dades al servidor i recarregar la pàgina. En una aplicació React això destrueix tot l'estat i reinicia l'aplicació: el símptoma és un parpelleig i un formulari buit, com si no hagués passat res. esdeveniment.preventDefault(), que ja coneixes de la lliçó 03-01, ho evita.

El flux complet de l'enviament

flowchart TD
    A["submit del formulari<br/>(botó o tecla Enter)"] --> B["gestionarEnviament(esdeveniment)"]
    B --> C["esdeveniment.preventDefault()"]
    C --> D["Construir l'objecte amb les dades de l'estat"]
    D --> E["Avisar el pare amb una prop de funció"]
    E --> F["Reiniciar l'estat del formulari"]

Reiniciar el formulari després d'enviar

Com que l'estat és l'única font de veritat, buidar el formulari és assignar els valors inicials. El patró net és guardar aquests valors en una constant:

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

function FormulariExemple() {
  const [dades, setDades] = useState(DADES_INICIALS);

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    // … processar
    setDades(DADES_INICIALS);   // el formulari es buida sol
  }
  // …
}

Dues advertències:

  • La constant va fora del component. A dins es recrearia a cada render sense necessitat.
  • DADES_INICIALS no s'ha de mutar mai. Com que setDades sempre crea objectes nous amb spread, l'objecte original roman intacte i es pot reutilitzar tantes vegades com calgui.

Existeix també esdeveniment.target.reset(), el mètode natiu del DOM, però no el facis servir en un formulari controlat: buidaria el DOM sense tocar l'estat, i React tornaria a posar els valors anteriors al següent render. Amb camps controlats, es reinicia l'estat.

  1. CicloUrbano: FormulariReserva

És el moment de juntar-ho tot. El formulari ha de permetre triar una bicicleta disponible, indicar quan comença la reserva, quantes hores dura i acceptar les condicions; en enviar-lo, construeix un objecte Reserva amb la forma del domini.js i l'entrega al pare.

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

// Constant fora del component: no es recrea a cada render
const DADES_INICIALS = {
  bicicletaId: '',
  dataInici: '',
  hores: 2,
  condicions: false
};

/**
 * Formulari de creació de reserves de CicloUrbano.
 * Props:
 *  - bicicletes (array, opcional, per defecte []): catàleg complet
 *  - usuariId (cadena, opcional, per defecte 'usr-01'): qui reserva
 *  - alCrearReserva (funció, opcional): rep l'objecte Reserva construït
 *
 * Tots els camps són CONTROLATS: l'estat `dades` és l'única font de veritat.
 * La validació arriba a la lliçó 03-05.
 */
function FormulariReserva({ bicicletes = [], usuariId = 'usr-01', alCrearReserva }) {
  const [dades, setDades] = useState(DADES_INICIALS);

  // Valors derivats: es recalculen a cada render
  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;
  const totalFormatat = total.toFixed(2).replace('.', ',');

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

    // Cada tipus de camp llegeix el seu valor d'un lloc diferent
    let valorFinal = value;
    if (type === 'checkbox') {
      valorFinal = checked;
    } else if (type === 'number') {
      valorFinal = value === '' ? '' : Number(value);
    }

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

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();   // sense això, la pàgina es recarregaria

    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);   // el formulari es buida
  }

  return (
    <form className={estils.formulari} onSubmit={gestionarEnviament}>
      <h2>Nova reserva</h2>

      <div className={estils.camp}>
        <label htmlFor="bicicletaId">Bicicleta</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          value={dades.bicicletaId}
          onChange={gestionarCanvi}
        >
          <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>
      </div>

      <div className={estils.camp}>
        <label htmlFor="dataInici">Inici de la reserva</label>
        <input
          id="dataInici"
          name="dataInici"
          type="datetime-local"
          value={dades.dataInici}
          onChange={gestionarCanvi}
        />
      </div>

      <div className={estils.camp}>
        <label htmlFor="hores">Durada (hores)</label>
        <input
          id="hores"
          name="hores"
          type="number"
          min={1}
          max={24}
          step={1}
          value={dades.hores}
          onChange={gestionarCanvi}
        />
      </div>

      <div className={estils.campCasella}>
        <label>
          <input
            name="condicions"
            type="checkbox"
            checked={dades.condicions}
            onChange={gestionarCanvi}
          />{' '}
          Accepto les condicions d'ús de CicloUrbano
        </label>
      </div>

      {escollida && (
        <p className={estils.total}>
          {escollida.model} · {dades.hores || 0} h · <strong>{totalFormatat} €</strong>
        </p>
      )}

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

export default FormulariReserva;

Repàs de les decisions preses:

  • DADES_INICIALS amb hores: 2 reprodueix el valor canònic de la reserva res-01 del domini.js, així el formulari proposa la durada habitual.
  • Un sol estat i un sol gestor per a quatre camps de tres tipus diferents, gràcies al name i al nom de propietat calculat.
  • La conversió per tipus està centralitzada a gestionarCanvi: checked per a la casella, Number per al camp numèric (respectant la cadena buida) i value tal qual per a la resta.
  • disponibles, escollida, total i totalFormatat són valors derivats. No hi ha ni un useState de més: guardar-los en estat els exposaria a desincronitzar-se, tal com vas aprendre a 02-04.
  • El total només apareix si hi ha bicicleta escollida, amb el && de la lliçó 03-02.
  • htmlFor a cada <label> apunta a l'id del camp. No és decoració: fa que en prémer l'etiqueta s'enfoqui el camp, i és el que permet a un lector de pantalla anunciar de quin camp es tracta. Es desenvolupa a Accessibilitat en Components Interactius.
  • crypto.randomUUID() genera l'identificador en el moment de crear la reserva, no durant el render. És exactament el consell de la lliçó anterior sobre generar ids en crear la dada.

App rep les reserves

// src/App.jsx (fragment)
import { useState } from 'react';
import { bicicletes, estacions, reserves as reservesInicials } from './dades/domini.js';
import FormulariReserva from './components/FormulariReserva.jsx';

function App() {
  const [reserves, setReserves] = useState(reservesInicials);

  function gestionarCrearReserva(reserva) {
    console.log('Nova reserva:', reserva);
    setReserves((anteriors) => [...anteriors, reserva]);   // array nou, sense mutar
  }

  return (
    <main>
      <FormulariReserva bicicletes={bicicletes} alCrearReserva={gestionarCrearReserva} />
      <p>Reserves registrades: {reserves.length}</p>
    </main>
  );
}

Omple el formulari i envia'l: a la consola apareix un objecte amb la mateixa forma exacta que res-01, i el comptador de reserves puja. El formulari es buida sol, perquè l'estat ha tornat a DADES_INICIALS.

Encara es pot enviar una reserva sense bicicleta, amb una data del passat o sense acceptar les condicions. Això és deliberat: la validació completa és el contingut de la propera lliçó.

Sobre biblioteques de formularis: en projectes grans es fan servir eines com React Hook Form o Formik, que redueixen el codi repetitiu i optimitzen els renders. Totes elles es recolzen en els conceptes d'aquesta lliçó, així que l'ordre correcte és aprendre primer el mecanisme i després, si el projecte ho demana, adoptar l'eina.

Errors Comuns i Consells

  • value sense onChange. El camp queda de només lectura i React avisa. Afegeix el gestor, o readOnly si és intencionat.
  • Inicialitzar l'estat amb undefined o null. Provoca l'avís «changing an uncontrolled input to be controlled». Fes servir '' per a textos i false per a caselles.
  • Llegir esdeveniment.target.value en una casella. Retorna "on", que sempre és truthy: la casella no es podrà desmarcar. Fes servir esdeveniment.target.checked.
  • Posar selected en una <option>. A React el valor va al <select>. React avisa si ho fas.
  • Escriure el contingut del textarea entre etiquetes. A React va a value.
  • Oblidar preventDefault() a onSubmit. La pàgina es recarrega i es perd tot l'estat. El símptoma és un parpelleig i el formulari en blanc.
  • Posar el gestor d'enviament al botó en lloc del <form>. Es perd l'enviament amb la tecla Enter.
  • Oblidar type="button" als botons auxiliars del formulari. Sense això són submit per defecte i enviaran el formulari en prémer-los.
  • Que el name del camp no coincideixi amb la clau de l'estat. El gestor genèric crearà una clau nova en silenci i el camp no respondrà.
  • Oblidar els parèntesis a (anterior) => ({ … }). Sense ells la fletxa retorna undefined i l'estat es trenca.
  • Guardar números com a cadenes. '3' + 1 és '31'. Converteix amb Number() al gestor, tractant la cadena buida a part.
  • Fer servir esdeveniment.target.reset() en un formulari controlat. Buida el DOM però no l'estat; React ho reverteix al següent render.
  • Consell: defineix els valors inicials en una constant fora del component. Serveix per al useState i per al reinici, sense duplicar.
  • Consell: no guardis en estat res que puguis calcular. El total, la bicicleta escollida i la validesa són valors derivats.
  • Consell: posa sempre id al camp i htmlFor a l'etiqueta. No costa res i arregla d'un cop la usabilitat i l'accessibilitat.

Exercicis

Exercici 1

Aquest formulari té cinc errors. Identifica'ls, explica el símptoma de cadascun i escriu la versió corregida.

function FormulariEstacio() {
  const [nom, setNom] = useState();
  const [barri, setBarri] = useState('Centre');
  const [activa, setActiva] = useState(false);

  function gestionarEnviament() {
    console.log({ nom, barri, activa });
  }

  return (
    <form>
      <input type="text" value={nom} />

      <select>
        <option value="Centre" selected>Centre</option>
        <option value="Nord">Nord</option>
      </select>

      <input
        type="checkbox"
        checked={activa}
        onChange={(e) => setActiva(e.target.value)}
      />

      <button type="submit" onClick={gestionarEnviament}>Desar</button>
    </form>
  );
}

Exercici 2

Crea el component FiltreCataleg (src/components/FiltreCataleg.jsx), un panell de filtres controlat amb un sol objecte d'estat i un gestor genèric. Ha d'incloure:

  • Un camp de text cerca per filtrar per model.
  • Un select tipus amb les opcions «todos», «urbana», «electrica» i «carga».
  • Un grup de ràdios estat amb «todos», «disponible», «alquilada» i «mantenimiento».
  • Una casella nomesAmbPlaces (booleana).
  • Un camp preuMaxim de tipus number entre 0 i 10, amb step de 0,5.
  • Un botó «Netejar filtres» que restableixi els valors inicials sense enviar el formulari.

El component ha d'avisar el pare amb alFiltrar(dades) a cada canvi.

Exercici 3

Amplia el FormulariReserva de l'apartat 12 amb dos camps nous, mantenint el mateix estat únic:

  1. Un select estacioDevolucio que llisti les tres estacions del domini.js, amb l'opció buida «La mateixa de recollida».
  2. Un textarea observacions d'un màxim de 300 caràcters, amb un comptador de caràcters restants.

Després, fes que l'objecte Reserva que s'entrega al pare inclogui tots dos camps, i explica per què el comptador de caràcters no ha de ser un useState a part.

Solucions

Solució 1.

Els cinc errors:

Error Símptoma
useState() sense valor inicial value={undefined} fa que el camp comenci com a no controlat; en escriure React avisa del canvi a controlat
El <input type="text">value però no onChange El camp no admet escriptura i React avisa que serà de només lectura
selected a l'<option> A React el valor va al <select>; a més falta l'onChange, així que el desplegable no respon
setActiva(e.target.value) en una casella value val "on", sempre truthy: la casella no es pot desmarcar
onClick al botó i no onSubmit al <form>, sense preventDefault No funciona l'enviament amb Enter, i en prémer el botó la pàgina es recarrega perdent-ho tot
// src/components/FormulariEstacio.jsx
import { useState } from 'react';

const BARRIS = ['Centre', 'Nord', 'Eixample'];

/**
 * Alta d'una estació de CicloUrbano.
 * Props:
 *  - alDesar (funció, opcional): rep { nom, barri, activa }
 */
function FormulariEstacio({ alDesar }) {
  const [nom, setNom] = useState('');
  const [barri, setBarri] = useState('Centre');
  const [activa, setActiva] = useState(false);

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    if (alDesar) {
      alDesar({ nom, barri, activa });
    }
  }

  return (
    <form onSubmit={gestionarEnviament}>
      <label htmlFor="nom">Nom de l'estació</label>
      <input
        id="nom"
        type="text"
        value={nom}
        onChange={(esdeveniment) => setNom(esdeveniment.target.value)}
      />

      <label htmlFor="barri">Barri</label>
      <select
        id="barri"
        value={barri}
        onChange={(esdeveniment) => setBarri(esdeveniment.target.value)}
      >
        {BARRIS.map((nomBarri) => (
          <option key={nomBarri} value={nomBarri}>
            {nomBarri}
          </option>
        ))}
      </select>

      <label>
        <input
          type="checkbox"
          checked={activa}
          onChange={(esdeveniment) => setActiva(esdeveniment.target.checked)}
        />{' '}
        Estació en servei
      </label>

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

export default FormulariEstacio;

Solució 2.

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

const FILTRES_INICIALS = {
  cerca: '',
  tipus: 'todos',
  estat: 'todos',
  nomesAmbPlaces: false,
  preuMaxim: 10
};

const TIPUS = ['todos', 'urbana', 'electrica', 'carga'];
const ESTATS = ['todos', 'disponible', 'alquilada', 'mantenimiento'];

/**
 * Panell de filtres del catàleg de CicloUrbano.
 * Props:
 *  - alFiltrar (funció, opcional): rep l'objecte de filtres a cada canvi
 */
function FiltreCataleg({ alFiltrar }) {
  const [filtres, setFiltres] = useState(FILTRES_INICIALS);

  function aplicar(nous) {
    setFiltres(nous);
    if (alFiltrar) {
      alFiltrar(nous);
    }
  }

  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);
    }

    aplicar({ ...filtres, [name]: valorFinal });
  }

  function gestionarNetejar() {
    aplicar(FILTRES_INICIALS);
  }

  return (
    <form className="filtre-cataleg" onSubmit={(esdeveniment) => esdeveniment.preventDefault()}>
      <div>
        <label htmlFor="cerca">Buscar model</label>
        <input
          id="cerca"
          name="cerca"
          type="text"
          value={filtres.cerca}
          onChange={gestionarCanvi}
        />
      </div>

      <div>
        <label htmlFor="tipus">Tipus</label>
        <select id="tipus" name="tipus" value={filtres.tipus} onChange={gestionarCanvi}>
          {TIPUS.map((tipus) => (
            <option key={tipus} value={tipus}>
              {tipus}
            </option>
          ))}
        </select>
      </div>

      <fieldset>
        <legend>Estat</legend>
        {ESTATS.map((estat) => (
          <label key={estat}>
            <input
              type="radio"
              name="estat"
              value={estat}
              checked={filtres.estat === estat}
              onChange={gestionarCanvi}
            />{' '}
            {estat}
          </label>
        ))}
      </fieldset>

      <label>
        <input
          name="nomesAmbPlaces"
          type="checkbox"
          checked={filtres.nomesAmbPlaces}
          onChange={gestionarCanvi}
        />{' '}
        Només estacions amb places lliures
      </label>

      <div>
        <label htmlFor="preuMaxim">Preu màxim per hora</label>
        <input
          id="preuMaxim"
          name="preuMaxim"
          type="number"
          min={0}
          max={10}
          step={0.5}
          value={filtres.preuMaxim}
          onChange={gestionarCanvi}
        />
      </div>

      {/* type="button" és imprescindible: sense això enviaria el formulari */}
      <button type="button" onClick={gestionarNetejar}>
        Netejar filtres
      </button>
    </form>
  );
}

export default FiltreCataleg;

Detalls: els ràdios comparteixen name="estat", així que el gestor genèric els tracta com un camp de text normal —el valor ve de value, no de checked—; el botó de netejar porta type="button"; i l'onSubmit del formulari només crida preventDefault() perquè prémer Enter al cercador no recarregui la pàgina.

Solució 3.

Els canvis sobre FormulariReserva:

const MAXIM_OBSERVACIONS = 300;

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

// Nova prop: estacions
function FormulariReserva({ bicicletes = [], estacions = [], usuariId = 'usr-01', alCrearReserva }) {
  const [dades, setDades] = useState(DADES_INICIALS);

  // Valor DERIVAT: es recalcula sol, mai es pot desincronitzar
  const restants = MAXIM_OBSERVACIONS - dades.observacions.length;

  // … gestionarCanvi sense cap canvi: el name i el spread ja cobreixen els camps nous …
      <div className={estils.camp}>
        <label htmlFor="estacioDevolucio">Estació de devolució</label>
        <select
          id="estacioDevolucio"
          name="estacioDevolucio"
          value={dades.estacioDevolucio}
          onChange={gestionarCanvi}
        >
          <option value="">La mateixa de recollida</option>
          {estacions.map((estacio) => (
            <option key={estacio.id} value={estacio.id}>
              {estacio.nom} ({estacio.barri})
            </option>
          ))}
        </select>
      </div>

      <div className={estils.camp}>
        <label htmlFor="observacions">Observacions</label>
        <textarea
          id="observacions"
          name="observacions"
          rows={3}
          maxLength={MAXIM_OBSERVACIONS}
          value={dades.observacions}
          onChange={gestionarCanvi}
        />
        <small>Queden {restants} caràcters.</small>
      </div>

I l'objecte de reserva:

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

Per què el comptador no ha de ser un useState a part: perquè restants es pot calcular sempre a partir de dades.observacions.length. Guardar-ho en estat crearia una segona font de veritat que caldria recordar d'actualitzar a cada canvi, al reinici del formulari i en qualsevol futura modificació del text. Si algú oblidés una d'aquestes actualitzacions, el comptador mostraria un número fals sense que res fallés visiblement. És la regla de la lliçó 02-04: si es pot derivar, no és estat. Afegir el camp nou al gestionarCanvi no ha requerit ni una línia, precisament perquè el gestor és genèric.

Conclusió

Els formularis són el punt on l'aplicació deixa de mostrar informació i comença a recollir-la, i React resol el problema de la doble font de veritat amb un patró únic: el component controlat. Ja saps que es construeix amb dues peces inseparables —value que imposa l'estat i onChange que avisa de l'intent de canvi—, i per què un value solitari deixa el camp congelat amb un avís molt explícit a la consola. Coneixes les particularitats de cada tipus de camp: el textarea que porta el contingut a value i no entre etiquetes; el select el valor del qual va a l'element i no a l'opció, amb la seva variant múltiple que treballa amb arrays; les caselles que fan servir checked en lloc de value; els grups de ràdios que comparteixen name i es marquen comparant amb l'estat; i els camps number i date, que sempre entreguen cadenes i exigeixen una conversió explícita respectant el cas del camp buit.

Sobre aquesta base has après el patró que fa manejable un formulari real: un sol objecte d'estat, un sol gestor genèric i la combinació de name amb nom de propietat calculat —{ ...anterior, [name]: valor }— que actualitza únicament el camp tocat sense mutar res. I l'enviament correcte: onSubmit al <form> perquè funcioni també amb Enter, preventDefault() perquè la pàgina no es recarregui i un reinici que consisteix simplement a retornar l'estat a la seva constant inicial.

CicloUrbano té per fi un FormulariReserva complet que construeix un objecte Reserva amb la forma exacta del domini.js i l'entrega al pare, mostrant de pas el total calculat a partir del preu de la bicicleta escollida. 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. A Validació de Formularis i Components No Controlats veuràs l'altra meitat de la història: quan cal validar i com fer-ho sense cridar a qui encara està escrivint, què ofereix i què no ofereix la validació nativa del navegador, i quan convé renunciar al control i deixar que sigui el DOM qui guardi el valor.

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