Hasta ahora todo lo que pintabas salía de datos fijos en el código: un array de héroes que nunca cambiaba. Pero una app de verdad reacciona: el usuario escribe, filtra, marca favoritos, y la pantalla responde. Para eso necesitas dos cosas nuevas: guardar datos que cambian (el estado) y reaccionar a lo que hace el usuario (los eventos). Y aquí, más que la sintaxis, importa el porqué de cada regla: React es muy permisivo y te deja hacer las cosas mal sin avisar, así que entender por qué se hacen de cierta forma es lo que separa que funcione de que parezca que funciona.
Por qué una variable normal no basta#
La idea ingenua sería: guardo el dato en una variable y la cambio. Probémoslo con un contador.
// Esto NO funciona. La pantalla siempre mostrará 0.
function Contador() {
// Una variable normal del componente.
let cuenta = 0;
return (
// Al hacer clic, sumamos 1 a la variable...
<button onClick={() => (cuenta = cuenta + 1)}>
Llevas {cuenta}
</button>
);
}Haces clic y no pasa nada. Por dos razones, y conviene entender las dos:
- React no vigila tus variables. Cambiar
cuentano le dice a React que tiene que repintar. La pantalla se quedó como estaba en el último render. - La función se ejecuta entera en cada render. Un componente es una función; cada vez que React lo pinta, la vuelve a llamar de arriba abajo, y
let cuenta = 0arranca otra vez en 0. Aunque el punto 1 se arreglara, el valor se perdería en el siguiente render.
Necesitas algo que (a) conserve el valor entre renders y (b) avise a React cuando cambie para que repinte. Eso es useState.
useState: memoria que provoca repintado#
useState es un hook: una función especial de React que le da capacidades extra a un componente. Le pasas el valor inicial y te devuelve un array con dos cosas: el valor actual y una función para cambiarlo.
import { useState } from "react";
function Contador() {
// useState(0): valor inicial 0.
// Devuelve [valor, función-para-cambiarlo]; lo desestructuramos (Nivel 3).
const [cuenta, setCuenta] = useState(0);
return (
// setCuenta cambia el valor Y le dice a React que repinte con el nuevo.
<button onClick={() => setCuenta(cuenta + 1)}>
Llevas {cuenta}
</button>
);
}Ahora sí: setCuenta hace dos cosas a la vez —guarda el valor nuevo y dispara un re-render—, y React conserva ese valor de un render al siguiente. Por convención, el par se nombra [algo, setAlgo].
Eventos: la trampa de los paréntesis#
Para reaccionar al usuario, a los elementos les pones manejadores: onClick, onChange, onSubmit. Aquí hay una regla que parece un detalle y es de las que más bugs causan: le pasas la función, no su llamada.
// Definimos el manejador una vez.
function manejarClic() {
console.log("clic");
}
// BIEN: le pasas la función (sin paréntesis). React la llamará cuando haya clic.
<button onClick={manejarClic}>Pulsar</button>
// MAL: con paréntesis la llamas TÚ, al renderizar, no al hacer clic.
<button onClick={manejarClic()}>Pulsar</button>¿Y qué tiene de malo el segundo? Que manejarClic() se ejecuta mientras React pinta el botón, no cuando el usuario hace clic, y lo que le llega a onClick es lo que la función devuelva (aquí, undefined). En el mejor caso, no reacciona al clic. En el peor —y es facilísimo caer—, si ese manejador cambia el estado, provocas un repintado, que vuelve a ejecutar la función, que cambia el estado otra vez: un bucle infinito que congela la pestaña.
Cuando necesitas pasarle un argumento, lo envuelves en una arrow function, que sí es “una función sin llamar”:
// La arrow function se pasa SIN ejecutar; React la llama en el clic, y entonces corre setFiltroRol("Daño").
<button onClick={() => setFiltroRol("Daño")}>Daño</button>El objeto del evento llega como argumento al manejador. TypeScript lo tipa solo según el elemento; si quieres nombrarlo, es React.ChangeEvent<HTMLInputElement> para un input, por ejemplo:
// evento.target es el input; evento.target.value, lo que tiene escrito.
function manejarCambio(evento: React.ChangeEvent<HTMLInputElement>) {
setBusqueda(evento.target.value);
}Actualizar el estado sin mutar (la gran prohibición)#
Esta es la regla que React no te obliga a cumplir y que más quebraderos da. Quieres añadir un id a una lista de favoritos. La tentación:
// MAL: muta el array y se lo devuelves a setFavoritos.
favoritos.push(id);
setFavoritos(favoritos);Esto compila, no da ningún error… y la lista no se actualiza en pantalla. ¿Por qué? Porque para decidir si repinta, React compara el estado nuevo con el anterior por referencia (mira si es el mismo objeto, no si su contenido cambió). push modifica el array en su sitio, así que le pasas exactamente el mismo array que ya tenía: React ve la misma referencia, concluye que nada ha cambiado y no repinta. El dato sí está dentro, pero la pantalla miente. Recuerda del Nivel 3: los objetos y arrays se comparan por referencia, no por contenido.
La forma correcta es no tocar el original: crear uno nuevo con lo que quieres, y pasarle ese.
// BIEN: un array NUEVO con los de antes (spread, Nivel 3) más el id.
setFavoritos([...favoritos, id]);
// Para quitar uno, también un array nuevo: filter devuelve otro array.
setFavoritos(favoritos.filter((fav) => fav !== id));Como la referencia es nueva, React sabe que algo cambió y repinta. La misma regla vale para objetos: setHero({ ...hero, nombre: "Otro" }), nunca hero.nombre = "Otro".
Un apunte que ahorra bugs sutiles: cuando el valor nuevo depende del anterior, pásale a la función una función en vez del valor directo. Así React te da el valor más reciente, aunque haya varias actualizaciones seguidas:
// favs es el valor más reciente del estado, lo dé React cuando lo dé.
setFavoritos((favs) => [...favs, id]);Inputs controlados#
Para un campo de texto, lo normal en React es que el estado mande: el value del input sale del estado, y cada tecla actualiza ese estado. A eso se le llama componente controlado.
// value lo fija el estado; onChange lo actualiza en cada pulsación.
<input
type="text"
value={busqueda}
onChange={(evento) => setBusqueda(evento.target.value)}
/>¿Y si pones value sin onChange? El input queda de solo lectura y no te deja escribir. La razón es coherente con todo lo anterior: en cada tecla, React repinta el input con el value del estado, que no ha cambiado, y borra lo que acabas de teclear. Con onChange actualizas el estado, el estado cambia, y entonces value refleja lo nuevo. La ventaja de controlarlo así es enorme: el valor del campo vive en tu estado, no escondido en el DOM, y puedes usarlo para filtrar, validar o lo que sea, sin ir a leerlo del navegador.
Levantar el estado para compartirlo#
El estado de useState es local: vive dentro del componente que lo declara, y sus hermanos no lo ven. Pero muchas veces dos componentes necesitan el mismo dato: unos botones de filtro lo cambian y un grid lo lee. ¿Dónde lo pones?
La respuesta es levantarlo: lo subes al ancestro común de los dos (aquí, App), y se lo pasas hacia abajo: el valor por props, y un callback para que el hijo pida cambiarlo.
// El estado vive en App, el ancestro común.
function App() {
const [filtroRol, setFiltroRol] = useState("Todos");
return (
<>
{/* Controls recibe el valor y un callback para cambiarlo. */}
<Controls filtroRol={filtroRol} onRol={setFiltroRol} />
{/* El grid lee el mismo estado para filtrar. */}
<Grid filtroRol={filtroRol} />
</>
);
}El patrón se resume en una frase: los datos bajan por props, los eventos suben por callbacks. Mantenerlo hace que, en una app de cientos de componentes, siempre sepas quién es el dueño de cada dato.
Pruébalo#
El Team Builder, ya interactivo: filtra por rol y busca por nombre. El grid se deriva del estado y se actualiza solo. Pulsa los botones y escribe en el buscador.
Comprueba lo que sabes#
Pregunta 1 de 4
Tienes `let cuenta = 0` y un botón que hace `cuenta++`. ¿Por qué la pantalla no se actualiza?
Tu turno#
Ejercicio · en esta página
El Team Builder reacciona: filtro y buscador
Guarda el rol seleccionado con useState, pinta un botón por rol que cambie el estado al hacer clic, y deriva la lista visible filtrando EQUIPO. El grid debe actualizarse solo.
Paso 1: Filtro con estado
- El rol seleccionado vive en useState
- Un botón por rol; su onClick cambia el estado (sin llamar la función con paréntesis)
- La lista visible se DERIVA con .filter, no se guarda en otro useState
- El grid reacciona a los clics
Paso 2: Buscador controlado
- Un input controlado (value + onChange) que filtra por nombre
- Los dos filtros (rol y búsqueda) se combinan
- El botón del rol activo se resalta con una clase condicional
Paso 3: Estado levantado y sin mutar
- Los controles viven en un componente Controls; el estado está en App y baja por props + callbacks
- Un toggle de favorito que actualiza el estado SIN mutar (array nuevo)
- Estado vacío cuando no hay coincidencias; aguanta a 375px
Ver soluciones
import { useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
// El equipo, ya dado.
const EQUIPO: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Los roles para los botones; "Todos" no filtra.
const ROLES = ["Todos", "Daño", "Apoyo", "Tanque"];
// HeroCard recibe un héroe por props y pinta su tarjeta.
function HeroCard({ hero }: { hero: Heroe }) {
// El winrate es un dato derivado del héroe.
const winrate = (hero.victorias / hero.partidas) * 100;
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{hero.nombre}</h2>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
</article>
);
}
export default function App() {
// Estado: el rol seleccionado. Empieza en "Todos". useState le da memoria al componente.
const [filtroRol, setFiltroRol] = useState("Todos");
// DERIVADO: la lista visible se calcula del estado, no se guarda en otro useState.
const visibles =
filtroRol === "Todos" ? EQUIPO : EQUIPO.filter((hero) => hero.rol === filtroRol);
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="controles">
{ROLES.map((rol) => (
// onClick recibe una FUNCIÓN; al hacer clic, cambia el estado y React repinta.
<button key={rol} className="boton" onClick={() => setFiltroRol(rol)}>
{rol}
</button>
))}
</div>
<div className="equipo">
{visibles.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
</section>
);
} Por qué este nivel
- El Team Builder deja de ser una foto fija: el estado (filtroRol) cambia con el clic y React repinta solo. La lista visible NO se guarda en otro estado, se deriva con .filter: guardar datos que puedes calcular es la primera fuente de bugs por desincronización.
- onClick={() => setFiltroRol(rol)} pasa una función. Si pusieras onClick={setFiltroRol(rol)} la llamarías al renderizar y entrarías en un bucle de repintados.
import { useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
// El equipo, ya dado.
const EQUIPO: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Los roles para los botones; "Todos" no filtra.
const ROLES = ["Todos", "Daño", "Apoyo", "Tanque"];
// HeroCard recibe un héroe por props y pinta su tarjeta.
function HeroCard({ hero }: { hero: Heroe }) {
// El winrate es un dato derivado del héroe.
const winrate = (hero.victorias / hero.partidas) * 100;
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{hero.nombre}</h2>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
</article>
);
}
export default function App() {
// Estado del filtro de rol.
const [filtroRol, setFiltroRol] = useState("Todos");
// Estado del buscador: un string que React controla.
const [busqueda, setBusqueda] = useState("");
// DERIVADO: filtra por rol Y por nombre. Dos filtros encadenados, ambos calculados del estado.
const visibles = EQUIPO
.filter((hero) => filtroRol === "Todos" || hero.rol === filtroRol)
.filter((hero) => hero.nombre.toLowerCase().includes(busqueda.toLowerCase()));
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="controles">
{ROLES.map((rol) => (
// El botón del rol activo se resalta con una clase condicional.
<button
key={rol}
className={rol === filtroRol ? "boton boton--activo" : "boton"}
onClick={() => setFiltroRol(rol)}
>
{rol}
</button>
))}
</div>
{/* Input controlado: value lo manda el estado y onChange lo actualiza en cada tecla. */}
<input
className="buscador"
type="text"
placeholder="Buscar héroe..."
value={busqueda}
onChange={(evento) => setBusqueda(evento.target.value)}
/>
<div className="equipo">
{visibles.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
</section>
);
} Por qué es mejor que el anterior
- El buscador es un input controlado: value lo manda el estado y onChange lo actualiza en cada tecla. React es la única fuente de verdad de lo que se ve en el campo; por eso puedes filtrar con ese mismo valor sin leer el DOM.
- Ambos filtros se derivan del estado y se encadenan. El botón activo se resalta con una clase condicional: la interfaz refleja el estado, no al revés.
import { useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
// El equipo, ya dado.
const EQUIPO: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Los roles para los botones; "Todos" no filtra.
const ROLES = ["Todos", "Daño", "Apoyo", "Tanque"];
// Controls: los filtros se PINTAN aquí, pero el estado VIVE en App (levantado).
// Recibe los valores actuales y unos callbacks para pedir el cambio hacia arriba.
function Controls({
filtroRol,
busqueda,
onRol,
onBusqueda,
}: {
filtroRol: string;
busqueda: string;
onRol: (rol: string) => void;
onBusqueda: (texto: string) => void;
}) {
return (
<>
<div className="controles">
{ROLES.map((rol) => (
<button
key={rol}
className={rol === filtroRol ? "boton boton--activo" : "boton"}
onClick={() => onRol(rol)}
>
{rol}
</button>
))}
</div>
<input
className="buscador"
type="text"
placeholder="Buscar héroe..."
value={busqueda}
onChange={(evento) => onBusqueda(evento.target.value)}
/>
</>
);
}
// HeroCard recibe si es favorito y un callback para alternarlo (datos abajo, eventos arriba).
function HeroCard({
hero,
esFavorito,
onToggle,
}: {
hero: Heroe;
esFavorito: boolean;
onToggle: (id: number) => void;
}) {
// El winrate es un dato derivado del héroe.
const winrate = (hero.victorias / hero.partidas) * 100;
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{hero.nombre}</h2>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
{/* La clase del botón depende del estado de favorito (renderizado condicional). */}
<button
className={esFavorito ? "favorito favorito--activo" : "favorito"}
onClick={() => onToggle(hero.id)}
>
★
</button>
</article>
);
}
export default function App() {
const [filtroRol, setFiltroRol] = useState("Todos");
const [busqueda, setBusqueda] = useState("");
// Estado de favoritos: un array de ids. Se actualiza SIN mutar.
const [favoritos, setFavoritos] = useState<number[]>([]);
// Toggle inmutable: ni push ni splice. Devolvemos siempre un array NUEVO,
// o React (que compara por referencia) no detectaría el cambio y no repintaría.
function toggleFavorito(id: number) {
// La forma de función usa el valor más reciente del estado.
setFavoritos((favs) =>
favs.includes(id) ? favs.filter((fav) => fav !== id) : [...favs, id],
);
}
// DERIVADO: la lista visible sale de los filtros, no de otro useState.
const visibles = EQUIPO
.filter((hero) => filtroRol === "Todos" || hero.rol === filtroRol)
.filter((hero) => hero.nombre.toLowerCase().includes(busqueda.toLowerCase()));
return (
<section>
<h1 className="titulo">Team Builder</h1>
{/* El estado vive aquí; baja a Controls por props y sube nuevo por los callbacks. */}
<Controls
filtroRol={filtroRol}
busqueda={busqueda}
onRol={setFiltroRol}
onBusqueda={setBusqueda}
/>
{visibles.length > 0 ? (
<div className="equipo">
{visibles.map((hero) => (
<HeroCard
key={hero.id}
hero={hero}
esFavorito={favoritos.includes(hero.id)}
onToggle={toggleFavorito}
/>
))}
</div>
) : (
<p className="equipo-vacio">Ningún héroe coincide con el filtro.</p>
)}
</section>
);
} Por qué es mejor que el anterior
- El estado vive en App y los controles en Controls: eso es levantar el estado. Controls recibe los valores por props y un callback para pedir el cambio hacia arriba. Datos hacia abajo, eventos hacia arriba: el patrón que mantiene predecible una app entera.
- toggleFavorito nunca muta: devuelve un array nuevo (con filter o con spread). Si hiciera push, React vería la misma referencia y no repintaría. Y usa la forma de función setFavoritos(favs => ...) para partir del valor más reciente del estado.