A 04-02 i 04-04 va quedar explicat per què existeixen els hooks: abans de 2019, reutilitzar lògica amb estat entre components obligava a embolicar-los en HOC o render props, amb l'infern d'embolcalls que allò produïa. Els cinc hooks que has estudiat en aquest mòdul resolen el problema de portes endins, però encara no has usat la peça que tanca el cercle: la possibilitat d'escriure els teus propis hooks. El cercador amb retard de 05-02, la subscripció a l'esdeveniment de connexió, el temporitzador de disponibilitat, el tema sincronitzat amb localStorage de 05-04… són fragments que es repeteixen en diferents components de CicloUrbano i que avui hauries de copiar i enganxar. En aquesta lliçó aprendràs a extreure'ls a funcions reutilitzables, construiràs una col·lecció completa de hooks per al projecte, i —el més important— entendràs la propietat que més es malinterpreta: un hook personalitzat comparteix lògica, no estat.
Contingut
- Què és un hook personalitzat
- Comparteix lògica, no estat: la demostració
- Quan extreure un hook
- Com s'extreu, pas a pas
useAlternar: el més simpleuseMagatzemLocal: estat sincronitzat amb el navegadoruseDebounce: retardar el terme de cercauseFetchBicicletes: càrrega de dades amb cancel·lacióuseEsdevenimentTeclatiuseAmpladaFinestra- Dissenyar l'API d'un hook
- Les regles continuen vigents
useIdi altres hooks de suport
- Què és un hook personalitzat
Un hook personalitzat és una funció de JavaScript el nom de la qual comença per
usei que crida altres hooks.
No hi ha res més. Ni una API especial, ni un registre, ni una configuració. Si escrius una funció anomenada useAlgo que a dins usa useState o useEffect, has creat un hook.
// src/hooks/useComptadorRenders.js
import { useRef, useEffect } from 'react';
export function useComptadorRenders(nom) {
const renders = useRef(0);
useEffect(() => {
renders.current += 1;
console.log(`${nom}: render núm. ${renders.current}`);
});
return renders.current;
}Dues coses fan que el prefix use no sigui opcional:
- És el que li diu a React i al linter que aquesta funció segueix les regles dels hooks (04-04). Sense el prefix,
eslint-plugin-react-hooksno pot comprovar que no la crides dins d'unifo d'un bucle, i perdries la xarxa de seguretat. - És el que li diu a qui llegeix el teu codi que aquesta funció no és una utilitat corrent: té estat, participa del cicle de render i no es pot cridar des de qualsevol lloc.
Al revés també val: si una funció no crida cap hook, no l'anomenis useAlgo. validarReserva i classes són funcions pures normals, i per això viuen a src/utilitats/ i no a src/hooks/.
- Comparteix lògica, no estat: la demostració
Aquesta és la propietat que més confusió genera, així que la demostrarem.
// src/hooks/useComptador.js
import { useState } from 'react';
export function useComptador(inicial = 0) {
const [valor, setValor] = useState(inicial);
const incrementar = () => setValor((previ) => previ + 1);
const reiniciar = () => setValor(inicial);
return { valor, incrementar, reiniciar };
}Ara dos components que l'usen, a la mateixa pantalla:
function PanellEsquerre() {
const { valor, incrementar } = useComptador();
return <button type="button" onClick={incrementar}>Esquerre: {valor}</button>;
}
function PanellDret() {
const { valor, incrementar } = useComptador();
return <button type="button" onClick={incrementar}>Dret: {valor}</button>;
}Prem cinc vegades el botó esquerre. Marcarà «Esquerre: 5» i «Dret: 0». Els dos components comparteixen la recepta, no el pot.
flowchart TD
H["useComptador()<br/><i>la lògica: una sola definició</i>"]
H -.->|"s'executa dins de"| PI["PanellEsquerre<br/><b>el seu propi useState → 5</b>"]
H -.->|"s'executa dins de"| PD["PanellDret<br/><b>el seu propi useState → 0</b>"]
style H fill:#e0f2fe
style PI fill:#dcfce7
style PD fill:#fde68a
La raó és la del mecanisme de 04-04: cada component té la seva pròpia llista ordenada de cel·les de hooks. Quan PanellEsquerre crida useComptador, el useState de dins ocupa una cel·la de PanellEsquerre; quan el crida PanellDret, ocupa una cel·la de PanellDret. Són magatzems diferents.
Un hook personalitzat és una plantilla de comportament, no un magatzem compartit. Cada crida crea un estat independent.
I d'aquí es dedueix la conseqüència pràctica: si el que vols és que diversos components vegin la mateixa dada, un hook personalitzat no n'hi ha prou. Necessites elevar l'estat (04-01) o un context (05-04). El més habitual és combinar-los: l'estat compartit viu en un proveïdor i el hook d'accés —useUsuari, useTema, useReserves— és un hook personalitzat que el llegeix. De fet, ja has escrit tres hooks personalitzats sense anomenar-los així.
- Quan extreure un hook
| Senyal | Exemple a CicloUrbano |
|---|---|
La mateixa parella useState + useEffect apareix en dos o més components |
La subscripció a resize a LlistaBicicletes i a PanellResum |
| Un component té tanta fontaneria que costa trobar el JSX | PanellActivitat amb càrrega, error, cancel·lació i bandera ignorar |
| Vols provar la lògica sense muntar la interfície | La validació amb retard del cercador |
| Un nom descriuria bé el que fa aquell bloc | useMagatzemLocal, useDebounce, useEsdevenimentTeclat |
| El mateix bug cal arreglar-lo en diversos llocs | Oblidar removeEventListener en tres components diferents |
I quan no extreure:
- Quan s'usa una sola vegada i el bloc és curt. Un
useEffectde tres línies dins del component es llegeix millor on és. - Quan el «hook» només embolica un altre sense aportar res.
function useNom() { return useState(''); }és indirecció pura: qui el llegeix ha d'obrir un altre fitxer per descobrir que no fa res. - Quan la lògica no usa cap hook. Això és una funció normal; portar-la a
src/utilitats/és la decisió correcta.
- Com s'extreu, pas a pas
Partim de l'AvisConnexio de 05-02 i el convertim en hook. El procés sempre és el mateix:
Pas 1: identificar el bloc complet. Estat, efecte i tot el que en depengui.
function AvisConnexio() {
const [enLinia, setEnLinia] = useState(() => navigator.onLine); // ← bloc
useEffect(() => { // ← bloc
function gestionarEnLinia() { setEnLinia(true); }
function gestionarSenseLinia() { setEnLinia(false); }
window.addEventListener('online', gestionarEnLinia);
window.addEventListener('offline', gestionarSenseLinia);
return () => {
window.removeEventListener('online', gestionarEnLinia);
window.removeEventListener('offline', gestionarSenseLinia);
};
}, []);
if (enLinia) return null; // ← això NO: és interfície
return <Avis to="advertencia">Sense connexió…</Avis>;
}Pas 2: moure el bloc a una funció amb prefix use, a src/hooks/.
Pas 3: decidir què retorna. El mínim que necessita el component: aquí, un booleà.
// src/hooks/useEstatConnexio.js
import { useState, useEffect } from 'react';
/**
* Indica si el navegador té connexió a la xarxa.
* Retorna: booleà
*/
export function useEstatConnexio() {
const [enLinia, setEnLinia] = useState(() => navigator.onLine);
useEffect(() => {
function gestionarEnLinia() { setEnLinia(true); }
function gestionarSenseLinia() { setEnLinia(false); }
window.addEventListener('online', gestionarEnLinia);
window.addEventListener('offline', gestionarSenseLinia);
return () => {
window.removeEventListener('online', gestionarEnLinia);
window.removeEventListener('offline', gestionarSenseLinia);
};
}, []);
return enLinia;
}Pas 4: el component es queda només amb la interfície.
// src/components/AvisConnexio.jsx
import { useEstatConnexio } from '../hooks/useEstatConnexio.js';
import Avis from './Avis.jsx';
function AvisConnexio() {
const enLinia = useEstatConnexio();
if (enLinia) return null;
return (
<Avis to="advertencia">
Sense connexió. Pots consultar el catàleg, però no confirmar reserves.
</Avis>
);
}
export default AvisConnexio;De vint línies a sis, i ara el botó «Confirmar reserva» de PanellReserva es pot desactivar sense connexió amb una sola línia: const enLinia = useEstatConnexio();.
useAlternar: el més simple
useAlternar: el més simpleComencem la col·lecció. Un booleà amb tres operacions amb nom, que apareix al Modal, l'Acordio i el PanellAvancat.
// src/hooks/useAlternar.js
import { useState, useCallback } from 'react';
/**
* Booleà amb operacions amb nom.
* Paràmetres:
* - inicial (booleà, opcional, per defecte false)
* Retorna: [valor, { activar, desactivar, alternar }]
*/
export function useAlternar(inicial = false) {
const [valor, setValor] = useState(inicial);
const activar = useCallback(() => setValor(true), []);
const desactivar = useCallback(() => setValor(false), []);
const alternar = useCallback(() => setValor((previ) => !previ), []);
return [valor, { activar, desactivar, alternar }];
}Línia a línia:
useState(inicial)guarda el booleà. Tota la resta són embolcalls sobre el seu actualitzador.activaridesactivarusen valor directe perquè no depenen de l'anterior;alternarusa la forma funcional perquè sí que en depèn (05-01).useCallbackamb[]fa que les tres funcions siguin estables entre renders. Sense això, cada render retornaria funcions noves, i un component que les posés a les dependències d'un efecte (05-02) el reexecutaria en bucle. Aquesta és l'única concessió al Mòdul 8 en tota la lliçó, i és de correcció, no de rendiment: els hooks personalitzats han de retornar valors estables (apartat 10).- Retorna un array perquè la primera posició és el valor principal i la segona un grup d'accions; així qui l'usa tria els noms.
// src/components/Acordio.jsx (fragment)
import { useAlternar } from '../hooks/useAlternar.js';
function Acordio({ titol, children }) {
const [obert, { alternar }] = useAlternar(false);
return (
<section>
<button type="button" onClick={alternar} aria-expanded={obert}>
{titol}
</button>
{obert && <div>{children}</div>}
</section>
);
}
useMagatzemLocal: estat sincronitzat amb el navegador
useMagatzemLocal: estat sincronitzat amb el navegadorCombina useState i useEffect perquè una dada sobrevisqui a la recàrrega de la pàgina. L'usa ProveidorTema (05-04), i l'usarà l'esborrany de reserva.
// src/hooks/useMagatzemLocal.js
import { useState, useEffect } from 'react';
/**
* Estat sincronitzat amb localStorage.
* Paràmetres:
* - clau (cadena, obligatòria): clau de localStorage
* - inicial (qualsevol, opcional): valor si no hi ha res desat
* Retorna: [valor, establirValor]
*/
export function useMagatzemLocal(clau, inicial = null) {
const [valor, setValor] = useState(() => {
// Inicialització mandrosa: només es llegeix del navegador una vegada (05-01)
try {
const desat = localStorage.getItem(clau);
return desat === null ? inicial : JSON.parse(desat);
} catch {
// JSON corrupte o localStorage bloquejat (mode privat, quotes)
return inicial;
}
});
useEffect(() => {
try {
localStorage.setItem(clau, JSON.stringify(valor));
} catch {
// Sense espai o sense permisos: l'aplicació ha de continuar funcionant
}
}, [clau, valor]);
return [valor, setValor];
}Els detalls que separen un hook de joguina d'un d'utilitzable:
- La lectura va a l'inicialitzador mandrós, no a un efecte. Si estigués en un efecte, el primer render mostraria el valor inicial i el segon el desat: un parpelleig visible.
- Els dos
try/catchno són paranoia.localStoragellança en mode privat d'alguns navegadors, quan se supera la quota, o quan el contingut desat no és JSON vàlid perquè una versió anterior de l'aplicació hi va desar una altra cosa. Una fallada aquí no ha de tombar l'aplicació. claués a les dependències de l'efecte perquè és un valor reactiu: si el component canvia de clau, cal desar a la nova.- Retorna un parell posicional, igual que
useState, perquè el reemplaçament sigui immediat: canviesuseState('clar')peruseMagatzemLocal('ciclourbano:tema', 'clar')i ja està.
// ProveidorTema (05-04) es simplifica així
const [tema, setTema] = useMagatzemLocal('ciclourbano:tema', 'clar');
useDebounce: retardar el terme de cerca
useDebounce: retardar el terme de cercaCercadorBicicletes no ha de filtrar a cada tecla: cal esperar que l'usuari deixi d'escriure. A 05-02 ho vas resoldre amb un efecte dins del component; ara es converteix en un hook reutilitzable.
// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';
/**
* Retorna una còpia retardada d'un valor.
* Paràmetres:
* - valor (qualsevol, obligatori)
* - retardMs (número, opcional, per defecte 400)
* Retorna: el valor després de `retardMs` sense canvis
*/
export function useDebounce(valor, retardMs = 400) {
const [valorRetardat, setValorRetardat] = useState(valor);
useEffect(() => {
const identificador = setTimeout(() => setValorRetardat(valor), retardMs);
return () => clearTimeout(identificador);
}, [valor, retardMs]);
return valorRetardat;
}El mecanisme és el que vas analitzar a 05-02: la neteja cancel·la el temporitzador pendent. Mentre l'usuari escrigui, cada tecla mata el temporitzador anterior i en programa un altre; només quan passen 400 ms sense canvis en sobreviu un i actualitza valorRetardat.
// src/components/CercadorBicicletes.jsx
import { useState, useEffect } from 'react';
import { useDebounce } from '../hooks/useDebounce.js';
/**
* Props:
* - alCercar (funció, obligatòria): rep el terme retardat
*/
function CercadorBicicletes({ alCercar }) {
const [text, setText] = useState('');
const termeRetardat = useDebounce(text, 400);
useEffect(() => {
alCercar(termeRetardat);
}, [termeRetardat, alCercar]);
return (
<input
type="search"
value={text}
onChange={(esdeveniment) => setText(esdeveniment.target.value)}
aria-label="Cercar bicicletes per model"
/>
);
}
export default CercadorBicicletes;Fixa't en el repartiment: el camp continua sent controlat i respon a l'instant (03-04), perquè ningú vol que l'escriptura es noti lenta; el que es retarda és únicament l'avís al pare.
useFetchBicicletes: càrrega de dades amb cancel·lació
useFetchBicicletes: càrrega de dades amb cancel·lacióEl cas més llarg, i el que més neteja el component. Reutilitza tot el de 05-02: AbortController, bandera ignorar, comprovació de resposta.ok i una única variable de fase.
// src/hooks/useFetchBicicletes.js
import { useState, useEffect } from 'react';
/**
* Carrega les bicicletes d'una estació des de l'API.
* Paràmetres:
* - estacioId (cadena, obligatòria)
* Retorna: { bicicletes, carregant, error }
*/
export function useFetchBicicletes(estacioId) {
const [bicicletes, setBicicletes] = useState([]);
const [fase, setFase] = useState('inactiu'); // 'inactiu' | 'carregant' | 'exit' | 'error'
const [error, setError] = useState(null);
useEffect(() => {
if (!estacioId) {
setBicicletes([]);
setFase('inactiu');
return;
}
const controlador = new AbortController();
let ignorar = false;
async function carregar() {
setFase('carregant');
setError(null);
try {
const resposta = await fetch(
`/api/estaciones/${estacioId}/bicicletas`,
{ signal: controlador.signal }
);
if (!resposta.ok) throw new Error(`El servidor ha respost ${resposta.status}`);
const dades = await resposta.json();
if (!ignorar) {
setBicicletes(dades);
setFase('exit');
}
} catch (fallada) {
if (fallada.name === 'AbortError') return;
if (!ignorar) {
setError(fallada.message);
setFase('error');
}
}
}
carregar();
return () => {
ignorar = true;
controlador.abort();
};
}, [estacioId]);
return { bicicletes, carregant: fase === 'carregant', error };
}Punts de disseny:
- La guarda inicial (
if (!estacioId)) permet usar el hook abans que hi hagi estació triada. Un hook no es pot cridar condicionalment, però pot sortir aviat per dins: la condició és dins de l'efecte, no al voltant del hook. faseés interna i no s'exposa. Cap enfora només surtencarregantierror, que és el que necessita la interfície. Amagar el detall és part del disseny de l'API.- Retorna un objecte, no un array, perquè són tres valors sense un ordre natural (apartat 10).
El component queda irreconeixible de net:
// src/components/PanellActivitat.jsx
import { useFetchBicicletes } from '../hooks/useFetchBicicletes.js';
import LlistaBicicletes from './LlistaBicicletes.jsx';
import Avis from './Avis.jsx';
function PanellActivitat({ estacioId }) {
const { bicicletes, carregant, error } = useFetchBicicletes(estacioId);
if (carregant) return <p aria-live="polite">Carregant bicicletes…</p>;
if (error) return <Avis to="error">No s'ha pogut carregar l'estació: {error}</Avis>;
return <LlistaBicicletes bicicletes={bicicletes} />;
}
export default PanellActivitat;Aquest hook és també la millor demostració de per què existeixen les biblioteques d'estat del servidor: el que li falta —memòria cau entre pantalles, desduplicació de peticions idèntiques, reintents, revalidació en tornar a la pestanya— no cap en vint línies, i és exactament el que resolen TanStack Query o SWR a 07-06.
useEsdevenimentTeclat i useAmpladaFinestra
useEsdevenimentTeclat i useAmpladaFinestraDos hooks curts que completen la col·lecció.
// src/hooks/useEsdevenimentTeclat.js
import { useEffect, useRef } from 'react';
/**
* Executa una acció en prémer una tecla concreta.
* Paràmetres:
* - tecla (cadena, obligatòria): valor d'event.key, p. ex. 'Escape'
* - accio (funció, obligatòria)
* - actiu (booleà, opcional, per defecte true)
*/
export function useEsdevenimentTeclat(tecla, accio, actiu = true) {
const accioGuardada = useRef(accio);
// Manté l'acció al dia sense tornar a subscriure l'escoltador
useEffect(() => {
accioGuardada.current = accio;
}, [accio]);
useEffect(() => {
if (!actiu) return;
function gestionarTecla(esdeveniment) {
if (esdeveniment.key === tecla) accioGuardada.current(esdeveniment);
}
window.addEventListener('keydown', gestionarTecla);
return () => window.removeEventListener('keydown', gestionarTecla);
}, [tecla, actiu]);
}El truc d'accioGuardada mereix explicació, perquè resol un problema real. Si accio estigués directament a les dependències del segon efecte, qualsevol component que passés una funció en línia (() => setObert(false)) provocaria una anul·lació de subscripció i una nova subscripció a cada render. Guardant la funció en una referència (05-03) i actualitzant-la en un efecte a part, l'escoltador es registra una sola vegada i tot i així sempre crida la versió més recent. És un patró habitual, conegut informalment com «esdeveniment efectiu».
// src/components/Modal.jsx (fragment) — tancar amb Escape
import { useEsdevenimentTeclat } from '../hooks/useEsdevenimentTeclat.js';
function Modal({ titol, children, alTancar }) {
useEsdevenimentTeclat('Escape', alTancar);
…
}// src/hooks/useAmpladaFinestra.js
import { useState, useEffect } from 'react';
/**
* Amplada actual de la finestra en píxels.
* Retorna: número
*/
export function useAmpladaFinestra() {
const [amplada, setAmplada] = useState(() => window.innerWidth);
useEffect(() => {
function gestionarCanviMida() {
setAmplada(window.innerWidth);
}
window.addEventListener('resize', gestionarCanviMida);
gestionarCanviMida(); // per si ha canviat entre el render i la subscripció
return () => window.removeEventListener('resize', gestionarCanviMida);
}, []);
return amplada;
}Aquest és l'exemple amb què 04-04 va obrir el mòdul: aquella lògica que calia copiar en tres components o embolicar en quatre HOC apilats cap avui en quinze línies i s'usa amb una. La col·lecció completa queda així:
| Hook | Fitxer | Retorna | Usat a |
|---|---|---|---|
useAlternar |
src/hooks/useAlternar.js |
[valor, { activar, desactivar, alternar }] |
Modal, Acordio, PanellAvancat |
useMagatzemLocal |
src/hooks/useMagatzemLocal.js |
[valor, establirValor] |
ProveidorTema, esborrany de reserva |
useDebounce |
src/hooks/useDebounce.js |
El valor retardat | CercadorBicicletes |
useFetchBicicletes |
src/hooks/useFetchBicicletes.js |
{ bicicletes, carregant, error } |
PanellActivitat |
useEsdevenimentTeclat |
src/hooks/useEsdevenimentTeclat.js |
Res | Modal, DialegReserva |
useAmpladaFinestra |
src/hooks/useAmpladaFinestra.js |
number |
LlistaBicicletes, Disseny |
useEstatConnexio |
src/hooks/useEstatConnexio.js |
boolean |
AvisConnexio, PanellReserva |
- Dissenyar l'API d'un hook
Un hook és una interfície pública: algú l'usarà sense llegir-ne l'interior. Aquestes quatre decisions marquen la diferència.
Què retornar: array o objecte
| Retorna | Quan | Exemple |
|---|---|---|
| Un valor solt | Només hi ha un resultat | useAmpladaFinestra() → 1280 |
| Un array | Dos valors amb ordre natural, i qui l'usa voldrà renombrar-los | useMagatzemLocal() → [valor, setValor] |
| Un objecte | Tres o més valors, o vols poder afegir-ne més endavant | useFetchBicicletes() → { bicicletes, carregant, error } |
El criteri de fons: l'array obliga a recordar l'ordre però permet renombrar (per això useState l'usa: se'n declaren diversos per component); l'objecte documenta cada valor amb el seu nom i permet afegir camps sense trencar res a ningú, cosa que amb un array de cinc posicions seria impossible.
Paràmetres amb valors per defecte
// ✅ El cas comú no exigeix configuració
export function useDebounce(valor, retardMs = 400) { … }
export function useAlternar(inicial = false) { … }
export function useEsdevenimentTeclat(tecla, accio, actiu = true) { … }Si el teu hook necessita cinc paràmetres, agrupa'ls en un objecte d'opcions amb valors per defecte: useFetchBicicletes(estacioId, { reintents = 0, intervalMs = 0 } = {}).
Retornar valors estables
És l'error de disseny més freqüent i el més difícil de diagnosticar:
// ❌ Retorna un objecte NOU a cada render
export function useReserva(bicicleta, hores) {
return { bicicleta, hores, total: hores * bicicleta.preuHora };
}
// Qui l'usi així entra en bucle infinit:
const reserva = useReserva(bici, 2);
useEffect(() => { registrar(reserva); }, [reserva]); // 💥 reserva canvia SEMPRELes funcions que retornis han de ser estables (useCallback amb dependències correctes, com a useAlternar) i els objectes també, o bé documenta clarament que no ho són. Els actualitzadors de useState i el despatxar de useReducer ja ho són de sèrie, així que retornar-los directament sempre és segur.
Mantenir el hook enfocat
// ❌ Massa responsabilitats: impossible d'anomenar bé i de reutilitzar
export function useTotElPanell(estacioId) {
// carrega dades + gestiona el formulari + controla el modal + escolta el teclat
}
// ✅ Quatre hooks petits que es combinen on calgui
const { bicicletes, carregant, error } = useFetchBicicletes(estacioId);
const [modalObert, { activar, desactivar }] = useAlternar();
useEsdevenimentTeclat('Escape', desactivar, modalObert);Un hook ha de poder descriure's en una frase. Si en escriure'n el comentari necessites una «i» i després una altra, són dos hooks.
- Les regles continuen vigents
Els hooks personalitzats són hooks, així que hereten les dues regles de 04-04 sense excepcions.
Regla 1: només al nivell superior. Ni dins de condicionals, ni de bucles, ni de funcions imbricades.
// ❌ Trenca la regla 1: la llista de cel·les canviaria entre renders
function PanellEstacio({ estacioId }) {
if (estacioId) {
const { bicicletes } = useFetchBicicletes(estacioId); // 💥
}
}
// ✅ El hook es crida sempre; la condició va DINS del hook
function PanellEstacio({ estacioId }) {
const { bicicletes, carregant } = useFetchBicicletes(estacioId); // gestiona null per dins
}Regla 2: només des de components de React o des d'altres hooks. Un hook personalitzat pot cridar altres hooks personalitzats sense límit; el que no pot és cridar-se des d'un gestor d'esdeveniments, des d'una funció normal o des del cos d'una classe.
// ✅ Composició de hooks: perfectament legítim
export function useCatalegFiltrat(estacioId, terme) {
const { bicicletes, carregant, error } = useFetchBicicletes(estacioId);
const termeRetardat = useDebounce(terme, 400);
const visibles = bicicletes.filter((bicicleta) =>
bicicleta.model.toLowerCase().includes(termeRetardat.toLowerCase())
);
return { visibles, carregant, error };
}Aquest exemple ensenya la propietat més útil de totes: els hooks es componen. useCatalegFiltrat no reimplementa res; encadena dos hooks existents i afegeix un filtre derivat. Igual que els components es construeixen amb altres components (04-02), els hooks es construeixen amb altres hooks.
useId i altres hooks de suport
useId i altres hooks de suportUn últim hook de la biblioteca estàndard que encaixa aquí i completa l'accessibilitat de 03-06: useId genera un identificador únic i estable per associar etiquetes i camps.
// src/components/CampHores.jsx
import { useId } from 'react';
function CampHores({ valor, alCanviar }) {
const idCamp = useId();
const idAjuda = `${idCamp}-ajuda`;
return (
<p>
<label htmlFor={idCamp}>Hores de la reserva</label>
<input
id={idCamp}
type="number"
min="1"
max="24"
value={valor}
onChange={(esdeveniment) => alCanviar(Number(esdeveniment.target.value))}
aria-describedby={idAjuda}
/>
<span id={idAjuda}>Entre 1 i 24 hores.</span>
</p>
);
}Per què no n'hi ha prou amb escriure id="hores" a mà: si CampHores apareix dues vegades a la mateixa pàgina —el formulari de reserva i el de modificació—, hi hauria dos elements amb el mateix id, i el <label> d'un activaria el camp de l'altre. useId garanteix unicitat per instància. I per què no val Math.random(): l'identificador ha de ser el mateix al servidor i al client si algun dia renderitzes al servidor (Mòdul 10), i a més s'ha de mantenir estable entre renders. useId compleix les dues coses.
Regla d'ús: useId és per a identificadors d'accessibilitat, no per a claus de llistes. Les key surten de les dades (03-03), mai d'un generador.
Errors Comuns i Consells
- Esperar que un hook comparteixi estat entre components. No ho fa: cada crida té el seu propi estat. Per compartir, context (05-04) o elevar l'estat (04-01).
- No començar el nom per
use. El linter deixa de vigilar la funció i les violacions de les regles passen desapercebudes fins que fallen en producció. - Posar
usea una funció que no crida cap hook. És una funció d'utilitat: porta-la asrc/utilitats/. - Crear un hook que només embolica un altre.
useNom() { return useState(''); }afegeix un fitxer i no aporta res. - Ficar-hi massa responsabilitats. Si el nom necessita una «i», són dos hooks.
- Retornar objectes o funcions inestables. Trenquen les dependències dels efectes de qui l'usi.
useCallbackper a les funcions; per als objectes, o bé els estabilitzes o bé ho documentes. - Cridar un hook personalitzat dins d'un
if. La regla 1 no es relaxa perquè sigui teu. La condició va dins del hook. - Cridar-lo des d'un gestor d'esdeveniments.
onClick={() => useAlternar()}no és vàlid: els hooks es criden durant el render. - Consell: escriu el hook la segona vegada que copiïs el mateix bloc, no la primera. Extreure massa aviat produeix abstraccions que no encaixen en el segon cas.
- Consell: documenta cada hook amb un comentari que digui quins paràmetres rep i què retorna. És l'única documentació que llegirà qui l'usi.
- Consell: si un hook és difícil d'anomenar, probablement fa massa coses. El nom és un bon detector de disseny.
Exercicis
Exercici 1. Aquests dos components de CicloUrbano repeteixen la mateixa lògica. Extreu-la a un hook useTemporitzadorDisponibilitat a src/hooks/, decideix què ha de retornar i reescriu els dos components usant-lo.
function DisponibilitatEstacio({ estacio }) {
const [placesLliures, setPlacesLliures] = useState(estacio.places);
const [ultimaLectura, setUltimaLectura] = useState(null);
useEffect(() => {
const id = setInterval(() => {
setPlacesLliures(consultarPlacesLliures(estacio.id));
setUltimaLectura(new Date());
}, 10000);
return () => clearInterval(id);
}, [estacio.id]);
return <p>{estacio.nom}: {placesLliures} places lliures</p>;
}
function ResumFlota({ estacio }) {
const [lliures, setLliures] = useState(estacio.places);
const [moment, setMoment] = useState(null);
useEffect(() => {
const id = setInterval(() => {
setLliures(consultarPlacesLliures(estacio.id));
setMoment(new Date());
}, 30000);
return () => clearInterval(id);
}, [estacio.id]);
return <span>Ocupació: {estacio.places - lliures}/{estacio.places}</span>;
}Exercici 2. Un company escriu aquest hook i es queixa que «el comptador es comparteix entre les dues targetes». Explica per què això és impossible, què observa en realitat i com aconseguiria de debò un comptador compartit.
export function useVistes() {
const [vistes, setVistes] = useState(0);
const registrar = () => setVistes((previes) => previes + 1);
return { vistes, registrar };
}Exercici 3. Escriu useValorPrevi(valor), un hook que retorni el valor que tenia el seu argument en el render anterior (i undefined en el primer). Després usa'l a ComptadorPlaces per mostrar si les places lliures han pujat o baixat respecte a la lectura anterior. Justifica per què el hook usa useRef i no useState.
Solucions
Solució 1.
// src/hooks/useTemporitzadorDisponibilitat.js
import { useState, useEffect } from 'react';
import { consultarPlacesLliures } from '../utilitats/disponibilitat.js';
/**
* Consulta periòdicament les places lliures d'una estació.
* Paràmetres:
* - estacio (objecte Estacio, obligatori)
* - intervalMs (número, opcional, per defecte 10000)
* Retorna: { placesLliures, ocupades, ultimaLectura }
*/
export function useTemporitzadorDisponibilitat(estacio, intervalMs = 10000) {
const [placesLliures, setPlacesLliures] = useState(estacio.places);
const [ultimaLectura, setUltimaLectura] = useState(null);
useEffect(() => {
const identificador = setInterval(() => {
setPlacesLliures(consultarPlacesLliures(estacio.id));
setUltimaLectura(new Date());
}, intervalMs);
return () => clearInterval(identificador);
}, [estacio.id, intervalMs]);
return {
placesLliures,
ocupades: estacio.places - placesLliures, // derivat: el calcula el hook, no el component
ultimaLectura
};
}function DisponibilitatEstacio({ estacio }) {
const { placesLliures } = useTemporitzadorDisponibilitat(estacio);
return <p>{estacio.nom}: {placesLliures} places lliures</p>;
}
function ResumFlota({ estacio }) {
const { ocupades } = useTemporitzadorDisponibilitat(estacio, 30000);
return <span>Ocupació: {ocupades}/{estacio.places}</span>;
}Tres decisions de disseny que convé justificar: retorna un objecte perquè són tres valors sense ordre natural; l'interval és un paràmetre amb valor per defecte, perquè els dos components el necessitaven diferent i aquesta era l'única diferència real entre ells; i ocupades es calcula dins del hook perquè és un derivat que tots dos consumidors voldrien, i així ningú s'equivoca en restar. I una conseqüència important de la propietat de l'apartat 2: els dos components tenen temporitzadors independents, amb freqüències diferents i estats separats. Compartir el hook no significa compartir el temporitzador.
Solució 2.
És impossible que el comptador es comparteixi: cada crida a useVistes() executa el seu propi useState, que ocupa una cel·la a la llista de hooks del component que crida. Dues targetes són dos components, amb dues llistes i dos estats. La funció useVistes és una plantilla; l'estat el crea React per instància.
El que el company observa serà una d'aquestes tres coses:
- No hi ha dues instàncies, sinó una. Si les dues «targetes» són en realitat el mateix component al qual se li canvien les props, comparteixen estat perquè són la mateixa instància. Es resol amb la prop
key(05-01). - L'estat no és al hook, sinó més amunt. Si el valor ve d'un context (05-04), és compartit per disseny i el hook només el llegeix.
- Ha declarat l'estat fora del hook, en l'àmbit del mòdul, que sí que és compartit i a més no provoca renders:
// ❌ Això sí que comparteix, i funciona malament: no repinta ningú
let vistes = 0;
export function useVistes() {
const registrar = () => { vistes += 1; };
return { vistes, registrar };
}Per aconseguir un comptador de debò compartit i reactiu, la resposta és el context:
// src/contextos/ContextVistes.jsx
import { createContext, useContext, useState } from 'react';
const ContextVistes = createContext(null);
export function ProveidorVistes({ children }) {
const [vistes, setVistes] = useState(0);
const registrar = () => setVistes((previes) => previes + 1);
return <ContextVistes value={{ vistes, registrar }}>{children}</ContextVistes>;
}
export function useVistes() {
const context = useContext(ContextVistes);
if (context === null) throw new Error('useVistes s\'ha d\'utilitzar dins de <ProveidorVistes>');
return context;
}Ara totes les targetes embolicades per <ProveidorVistes> llegeixen i modifiquen el mateix comptador. Fixa't que useVistes continua sent un hook personalitzat: el que canvia no és el hook, sinó on viu l'estat.
Solució 3.
// src/hooks/useValorPrevi.js
import { useRef, useEffect } from 'react';
/**
* Retorna el valor que tenia l'argument en el render anterior.
* Paràmetres:
* - valor (qualsevol)
* Retorna: el valor previ, o undefined en el primer render
*/
export function useValorPrevi(valor) {
const referencia = useRef(undefined);
useEffect(() => {
referencia.current = valor; // s'escriu DESPRÉS del render (05-03)
}, [valor]);
return referencia.current; // durant el render, encara conté el valor anterior
}// src/components/ComptadorPlaces.jsx
import { useValorPrevi } from '../hooks/useValorPrevi.js';
import estils from './ComptadorPlaces.module.css';
import { classes } from '../utilitats/classes.js';
function ComptadorPlaces({ estacio, placesLliures }) {
const placesPrevies = useValorPrevi(placesLliures);
const tendencia =
placesPrevies === undefined || placesPrevies === placesLliures
? 'estable'
: placesLliures > placesPrevies
? 'pujant'
: 'baixant';
const SIMBOLS = { pujant: '▲', baixant: '▼', estable: '=' };
return (
<p className={classes(estils.comptador, estils[tendencia])}>
{estacio.nom}: {placesLliures} places
<span aria-hidden="true"> {SIMBOLS[tendencia]}</span>
<span className={estils.ocult}>
{tendencia === 'pujant' ? 'en augment' : tendencia === 'baixant' ? 'en descens' : 'sense canvis'}
</span>
</p>
);
}Per què useRef i no useState: escriure el valor previ no ha de provocar un render. Amb useState, cada actualització del valor dispararia un render extra per guardar la còpia, aquest render tornaria a comparar, i en el millor dels casos duplicaries els repintats; en el pitjor, entraries en bucle. I hi ha una raó més profunda: el valor previ no és una dada nova que l'aplicació produeixi, sinó memòria del que ja s'ha pintat. Encaixa exactament en la definició de useRef de 05-03: alguna cosa que es recorda entre renders sense formar part de cap.
L'aria-hidden sobre el símbol i el text alternatiu ocult vénen de 03-06 i de la convenció d'EtiquetaEstat: un caràcter com ▲ no significa res per a un lector de pantalla.
Conclusió
Un hook personalitzat és simplement una funció que comença per use i crida altres hooks, i amb això es tanca el cercle que 04-04 va obrir: la reutilització de lògica amb estat sense un sol embolcall a l'arbre de components. La propietat que cal tenir sempre present és que comparteix lògica, no estat: cada component que crida useComptador, useAlternar o useTemporitzadorDisponibilitat obté la seva pròpia còpia independent, i compartir de debò una dada continua sent feina d'elevar l'estat o del context. Has après a extreure un hook pas a pas a partir de codi ja escrit, i has construït la col·lecció de CicloUrbano a src/hooks/: useAlternar, useMagatzemLocal amb serialització segura, useDebounce per al cercador, useFetchBicicletes amb AbortController i cancel·lació, useEsdevenimentTeclat per tancar el Modal amb Escape, useAmpladaFinestra i useEstatConnexio. I amb ells, els criteris de disseny que fan que un hook sigui usable per una altra persona: array per a parells posicionals i objecte per a tres o més valors, paràmetres amb valors per defecte, valors de retorn estables i una sola responsabilitat per hook. Les dues regles de 04-04 continuen intactes, i a canvi els hooks es componen entre ells igual que els components.
Amb això acaba el Mòdul 5 i, amb ell, el nucli de React. En sis lliçons has passat de l'useState bàsic al control complet de l'estat: la instantània del render i les actualitzacions en cua (05-01), la sincronització amb sistemes externs i les condicions de carrera (05-02), la memòria que no repinta i l'accés al DOM (05-03), la fi de la perforació de props (05-04), l'estat complex governat per accions i reductors purs (05-05) i la teva pròpia lògica convertida en hooks reutilitzables (05-06). CicloUrbano ja és una aplicació amb catàleg, filtres, reserves validades, avisos, tema visual, usuari i càrrega de dades.
Li falta una cosa evident: és una sola pantalla. La capçalera porta des del mòdul 2 amb un menú «Catàleg · Estacions · Les meves reserves» els enllaços del qual no porten enlloc, perquè fins ara no hi havia manera que l'URL del navegador i la interfície es correspongués. Això vol dir adreces que es poden compartir, botons d'endarrere i endavant que funcionen, i seccions que es carreguen només quan calen. El Mòdul 6: Enrutament en React ho resol amb React Router: què és i per què no ve inclòs a React, com es configura, rutes imbricades perquè Disseny embolcalli totes les pantalles, navegació programàtica després de confirmar una reserva i rutes protegides que només vegi l'operari usr-02. La lliçó següent és Introducció a React Router.
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
