learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Estado complejo y memoización

useReducer para centralizar estado con muchas transiciones, y la memoización (memo, useMemo, useCallback) con lo que de verdad importa, que es cuándo NO usarla y por qué el React Compiler la está jubilando.

El estado de tu Team Builder ha ido creciendo: favoritos, filtros, carga, error. Mientras cada pieza es un valor suelto, useState va de sobra. Pero llega un punto en que el estado tiene muchas transiciones relacionadas —añadir un héroe, quitarlo, vaciar el equipo, con sus reglas— y repartir esa lógica por varios handlers se vuelve un lío. Para eso está useReducer. Y, por otro lado, a medida que la app crece aparece la pregunta del rendimiento: la memoización. Dos temas, y en los dos lo que importa no es la sintaxis, sino saber cuándo usarlos y, sobre todo, cuándo no.

useReducer: centralizar la lógica del estado#

Imagina el estado del equipo con useState: un setEquipo aquí para añadir, otro allá para quitar, otro para vaciar, y las reglas (no repetir, máximo seis) repartidas entre los handlers. Funciona, pero la lógica está esparcida y es fácil que dos sitios la apliquen distinto.

useReducer le da la vuelta: toda la lógica de cómo cambia el estado vive en una sola función, el reducer. El componente solo despacha acciones que describen lo que ha pasado.

tsx
// El estado y las acciones posibles (unión discriminada, Nivel 5).
interface EstadoEquipo {
  equipo: Heroe[];
}
type AccionEquipo =
  | { type: "añadir"; hero: Heroe }
  | { type: "quitar"; id: number }
  | { type: "vaciar" };

// El reducer: una función PURA que, según la acción, devuelve el estado NUEVO.
function reducer(estado: EstadoEquipo, accion: AccionEquipo): EstadoEquipo {
  switch (accion.type) {
    case "añadir":
      return { equipo: [...estado.equipo, accion.hero] };
    case "quitar":
      return { equipo: estado.equipo.filter((h) => h.id !== accion.id) };
    case "vaciar":
      return { equipo: [] };
  }
}

Y en el componente:

tsx
// useReducer devuelve el estado y dispatch, la función para disparar acciones.
const [estado, dispatch] = useReducer(reducer, { equipo: [] });

// El componente no toca el estado: solo dice QUÉ pasó. El reducer decide el cómo.
<button onClick={() => dispatch({ type: "añadir", hero })}>Añadir</button>

Fíjate en tres cosas, que son el porqué de todo esto:

  1. El reducer es puro. Recibe estado y acción, devuelve el estado nuevo, y nunca muta ni hace efectos. Por eso puedes leerlo de un vistazo y testearlo solo, sin montar el componente.
  2. Las acciones describen intención. dispatch({ type: "añadir", hero }) dice qué ha pasado, no cómo cambiar el estado. La lógica del cómo está en un único sitio.
  3. La unión discriminada lo hace seguro. Como las acciones son una unión por type, el switch es exhaustivo: si mañana añades una acción y olvidas tratarla, TypeScript te lo marca.

useState o useReducer#

No es que uno sea mejor. Es cuándo:

  • useState para valores simples e independientes: un texto de búsqueda, un booleano, un contador.
  • useReducer cuando hay varias transiciones relacionadas o reglas que conviene tener juntas: un carrito, un formulario con muchos campos, el equipo de este capítulo.

Memoización: el problema de la identidad referencial#

Cambiamos de tema, al rendimiento. Para entender la memoización hay que entender primero por qué React re-renderiza de más.

Cada vez que un componente se renderiza, su función se ejecuta entera. Y cada objeto, array o función que creas dentro se construye de cero: es una referencia nueva, aunque su contenido sea idéntico al de antes.

tsx
function App() {
  // En CADA render, este objeto y esta función son NUEVOS (otra referencia),
  // aunque parezcan iguales.
  const config = { tema: "claro" };
  const alClic = () => console.log("clic");
  // ...
}

Esto importa porque React, para decidir si un hijo debe re-renderizarse, compara sus props por referencia (comparación superficial): no mira si el contenido cambió, mira si es el mismo objeto. Así que un hijo que recibe config={config} ve una prop “nueva” en cada render del padre, y se re-renderiza, aunque nada haya cambiado de verdad. A esto se le llama identidad referencial, y es la raíz de la memoización.

memo, useMemo y useCallback#

Son las tres herramientas para cortar esos re-renders. Cada una congela una cosa distinta.

memo envuelve un componente para que se salte el re-render si sus props no cambian (por referencia):

tsx
// HeroCard no se re-renderiza si hero y onQuitar siguen siendo las mismas referencias.
const HeroCard = memo(function HeroCard({ hero, onQuitar }: Props) {
  return <article>{hero.nombre}</article>;
});

useMemo memoiza un valor calculado: lo recalcula solo cuando cambian sus dependencias.

tsx
// El ranking solo se reordena cuando cambia "equipo", no en cada render.
const ranking = useMemo(() => {
  return [...equipo].sort((a, b) => winrate(b) - winrate(a));
}, [equipo]);

useCallback memoiza una función: devuelve la misma referencia mientras no cambien sus dependencias.

tsx
// alQuitar mantiene su referencia, así el HeroCard memoizado no se re-renderiza por su culpa.
const alQuitar = useCallback((id: number) => {
  dispatch({ type: "quitar", id });
}, []);

En el demo de abajo lo ves en vivo: el mismo hijo, con y sin memo, y cuántas veces se renderiza cada uno cuando el padre cambia.

Cuándo NO memoizar (esto es lo importante)#

Aquí está la trampa en la que cae todo el mundo: memoizar “por si acaso”. La memoización no es gratis. Cada useMemo/useCallback ocupa memoria, añade la comparación de dependencias y ensucia el código. Y solo sirve en condiciones concretas:

  • memo solo ayuda si el componente es de verdad caro de renderizar y sus props son referencialmente estables. Sobre un componente barato, o con props nuevas en cada render, no hace nada (o pierdes con la comparación).
  • useCallback/useMemo solo importan si lo que memoizan es una dependencia de un efecto o se pasa a un hijo envuelto en memo. Una función que solo usas en un onClick local no gana nada por envolverla en useCallback.

La regla sana: escribe el código simple primero, mide si hay un problema de rendimiento, y memoiza solo donde lo haya. La inmensa mayoría de los componentes son tan baratos que memoizarlos cuesta más de lo que ahorra.

El React Compiler lo cambia casi todo#

Y ahora la vuelta de tuerca honesta: todo este apartado de memoización manual está de salida. React 19 trae el React Compiler, que analiza tu código en el build y aplica la memoización necesaria por ti, de forma automática y más fina que a mano.

Con el compilador activado, escribes el componente simple —sin memo, sin useMemo, sin useCallback— y obtienes la misma optimización (o mejor). Entonces, ¿por qué aprender esto? Por dos razones: para leer y mantener el código que ya existe (millones de líneas con memoización manual) y para entender qué hace el compilador por debajo. Lo que escribirás tú, cada vez más, es código simple.

Pruébalo#

Demo 1: useReducer. Añade y quita héroes; toda la lógica vive en el reducer.

Demo 2: memo en vivo. Pulsa el botón: el padre se re-renderiza. El hijo normal sube su contador de renders cada vez; el hijo con memo se queda quieto, porque sus props no cambian.

Comprueba lo que sabes#

Pregunta 1 de 4

¿Cuándo conviene useReducer en vez de useState?

Tu turno#

Ejercicio · en esta página

El equipo, con useReducer

Gestiona el equipo (añadir, quitar, vaciar) con useReducer y un reducer puro con acciones tipadas. En excelente, memoiza el ranking con useMemo.

Paso 1: useReducer con acciones tipadas

  • Estado del equipo y una unión discriminada de acciones (añadir, quitar, vaciar)
  • Un reducer puro que devuelve el estado nuevo sin mutar
  • Roster con Añadir, equipo con Quitar y un botón Vaciar; todo funciona
Ver soluciones
import { useReducer } from "react";

interface Heroe {
  id: number;
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
}

const ROSTER: 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 },
];

// El estado del equipo: la lista de héroes seleccionados.
interface EstadoEquipo {
  equipo: Heroe[];
}

// Las acciones, como UNIÓN DISCRIMINADA (Nivel 5): el campo type distingue cada una.
type AccionEquipo =
  | { type: "añadir"; hero: Heroe }
  | { type: "quitar"; id: number }
  | { type: "vaciar" };

// El reducer es PURO: recibe estado + acción y devuelve el estado NUEVO, sin mutar.
function reducerEquipo(estado: EstadoEquipo, accion: AccionEquipo): EstadoEquipo {
  switch (accion.type) {
    case "añadir":
      // Un array nuevo con los de antes más el héroe.
      return { equipo: [...estado.equipo, accion.hero] };
    case "quitar":
      // Un array nuevo sin el héroe del id dado.
      return { equipo: estado.equipo.filter((hero) => hero.id !== accion.id) };
    case "vaciar":
      return { equipo: [] };
  }
}

// HeroCard reutilizable: quien la usa decide el texto y la acción del botón.
function HeroCard({ hero, textoBoton, claseBoton, onAccion }: {
  hero: Heroe;
  textoBoton: string;
  claseBoton: string;
  onAccion: () => void;
}) {
  const winrate = (hero.victorias / hero.partidas) * 100;
  return (
    <article className="tarjeta">
      <h3 className="tarjeta__nombre">{hero.nombre}</h3>
      <p className="tarjeta__rol">{hero.rol}</p>
      <p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
      <button className={claseBoton} onClick={onAccion}>{textoBoton}</button>
    </article>
  );
}

export default function App() {
  // dispatch dispara acciones; el reducer decide el estado nuevo a partir de ellas.
  const [estado, dispatch] = useReducer(reducerEquipo, { equipo: [] });

  return (
    <section>
      <h1 className="titulo">Team Builder</h1>

      <div className="seccion">
        <h2 className="seccion__titulo">Roster</h2>
        <div className="lista">
          {ROSTER.map((hero) => (
            <HeroCard
              key={hero.id}
              hero={hero}
              textoBoton="Añadir"
              claseBoton="boton"
              onAccion={() => dispatch({ type: "añadir", hero })}
            />
          ))}
        </div>
      </div>

      <div className="seccion">
        <h2 className="seccion__titulo">Tu equipo</h2>
        {estado.equipo.length === 0 ? (
          <p className="equipo-vacio">Aún no has añadido héroes.</p>
        ) : (
          <div className="lista">
            {estado.equipo.map((hero) => (
              <HeroCard
                key={hero.id}
                hero={hero}
                textoBoton="Quitar"
                claseBoton="boton boton--quitar"
                onAccion={() => dispatch({ type: "quitar", id: hero.id })}
              />
            ))}
          </div>
        )}
        {estado.equipo.length > 0 && (
          <button
            className="boton boton--quitar"
            onClick={() => dispatch({ type: "vaciar" })}
          >
            Vaciar equipo
          </button>
        )}
      </div>
    </section>
  );
}

Por qué este nivel

  • La lógica del equipo deja de estar repartida en handlers sueltos: vive en un reducer puro. dispatch({ type: 'añadir', hero }) describe QUÉ pasó; el reducer decide el estado nuevo. La unión discriminada de acciones hace que el switch sea exhaustivo: si añades una acción nueva y olvidas tratarla, TypeScript te avisa.