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
- El conflicte: dues fonts de veritat
- Què és un component controlat
value+onChangepas a pas- Un
valuesenseonChange: el camp congelat - Camps de text i
textarea - El desplegable
select, simple i múltiple - Caselles de verificació amb
checked - Grups de botons de ràdio
numberidate: la conversió de tipus- Un sol estat per a tot el formulari
- L'enviament:
onSubmitipreventDefault - CicloUrbano:
FormulariReserva
- 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.
- 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».
value + onChange pas a pas
value + onChange pas a pasEls dos ingredients són inseparables. Analitzem-los per separat.
value: l'estat mana
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
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 decurrentTarget, 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.onChangea React es dispara a cada pulsació de tecla, a diferència de l'esdevenimentchangenatiu 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.
- Un
value sense onChange: el camp congelat
value sense onChange: el camp congelatAquest é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.
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
valueprop to a form field without anonChangehandler. This will render a read-only field. If the field should be mutable usedefaultValue. Otherwise, set eitheronChangeorreadOnly.
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
- Camps de text i
textarea
textareaEls 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:
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;
- El desplegable
select, simple i múltiple
select, simple i múltipleAquí 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
valuedelselectha de coincidir amb elvalued'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
mapnecessitenkey, com qualsevol llista (lliçó 03-03). esdeveniment.target.valuesempre és una cadena. Si els teus identificadors fossin numèrics, caldrien conversions; amb elsest-01de 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.
- Caselles de verificació amb
checked
checkedUna 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à.
- 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».
number i date: la conversió de tipus
number i date: la conversió de tipusAquí 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 sortLa 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.
- 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
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
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 |
- L'enviament:
onSubmit i preventDefault
onSubmit i preventDefaultEl 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:
- Prement el botó
type="submit". - Prement
Enteren qualsevol camp de text del formulari. - 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_INICIALSno s'ha de mutar mai. Com quesetDadessempre 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.
- CicloUrbano:
FormulariReserva
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_INICIALSambhores: 2reprodueix el valor canònic de la reservares-01deldomini.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
namei al nom de propietat calculat. - La conversió per tipus està centralitzada a
gestionarCanvi:checkedper a la casella,Numberper al camp numèric (respectant la cadena buida) ivaluetal qual per a la resta. disponibles,escollida,totalitotalFormatatsón valors derivats. No hi ha ni unuseStatede 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. htmlFora cada<label>apunta a l'iddel 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
valuesenseonChange. El camp queda de només lectura i React avisa. Afegeix el gestor, oreadOnlysi és intencionat.- Inicialitzar l'estat amb
undefinedonull. Provoca l'avís «changing an uncontrolled input to be controlled». Fes servir''per a textos ifalseper a caselles. - Llegir
esdeveniment.target.valueen una casella. Retorna"on", que sempre és truthy: la casella no es podrà desmarcar. Fes serviresdeveniment.target.checked. - Posar
selecteden una<option>. A React el valor va al<select>. React avisa si ho fas. - Escriure el contingut del
textareaentre etiquetes. A React va avalue. - Oblidar
preventDefault()aonSubmit. 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 teclaEnter. - Oblidar
type="button"als botons auxiliars del formulari. Sense això sónsubmitper defecte i enviaran el formulari en prémer-los. - Que el
namedel 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 retornaundefinedi l'estat es trenca. - Guardar números com a cadenes.
'3' + 1és'31'. Converteix ambNumber()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
useStatei 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
idal camp ihtmlFora 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
cercaper filtrar per model. - Un
selecttipusamb les opcions «todos», «urbana», «electrica» i «carga». - Un grup de ràdios
estatamb «todos», «disponible», «alquilada» i «mantenimiento». - Una casella
nomesAmbPlaces(booleana). - Un camp
preuMaximde tipusnumberentre 0 i 10, ambstepde 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:
- Un
selectestacioDevolucioque llisti les tres estacions deldomini.js, amb l'opció buida «La mateixa de recollida». - Un
textareaobservacionsd'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"> té 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
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
