Capítulo 06 · Profundiza

React: rendimiento y arquitectura

La app de escritorio recibe mensajes de WebSocket cada pocos segundos durante todo el turno: si cada mensaje redibuja media aplicación, la PC de la caja lo va a resentir. Este capítulo explica por qué React re-renderiza, cómo evitarlo con criterio y cómo organizar el código para que crezca sin dolor.

6.1 Por qué re-renderiza React (el modelo mental)

Casi toda optimización de React se reduce a entender tres reglas:

  1. Un componente se re-renderiza cuando cambia su estado (setState / useState) o cuando se re-renderiza su padre. Las props no necesitan "cambiar": si el padre se renderiza, el hijo también, por defecto.
  2. Renderizar no es tocar el DOM. Renderizar es ejecutar tu función y producir una descripción de la UI; después React la compara con la anterior (reconciliación) y solo aplica al DOM las diferencias reales. Por eso muchos re-renders son baratos… pero no gratis: tu función corre, con todo lo que calcule adentro.
  3. Las comparaciones son por identidad, no por contenido. {} !== {} y () => {} !== () => {}. Cada render crea objetos y funciones nuevos, y eso es lo que rompe las memoizaciones ingenuas.

El caso que nos importa: el mapa del capítulo 4.2 recibe un mensaje de ubicación → setPosiciones → se re-renderiza MapaRepartos… y con él todos sus hijos, incluida esa lista de 200 pedidos del panel lateral que no tiene nada que ver con el mensaje. Diez mensajes por segundo × 200 filas = una app que se arrastra. Las secciones siguientes son las herramientas para cortar esa cascada.

Mide antes de optimizar. Instala la extensión React Developer Tools y usa su pestaña Profiler: graba 10 segundos de uso, y te muestra qué componentes se renderizaron, cuántas veces y por qué ("porque el padre", "porque cambió la prop X"). Optimizar sin medir produce código lleno de useMemo que no arregla nada y sí dificulta leer. La regla profesional: primero el Profiler, luego el remedio, luego el Profiler otra vez para confirmar.

6.2 Las tres herramientas de memoización

HerramientaQué memorizaCuándo usarla
React.memo(Comp)El componente entero: si sus props no cambiaron (comparación superficial), no se re-renderiza aunque el padre síComponentes que reciben las mismas props a menudo y cuestan trabajo (una fila compleja, el mapa, una gráfica)
useMemo(fn, deps)El resultado de un cálculo entre rendersCálculos caros (ordenar/filtrar listas grandes) o para mantener identidad estable de objetos/arrays que se pasan como props
useCallback(fn, deps)La identidad de una función entre rendersFunciones que se pasan a componentes envueltos en React.memo (sin esto, la prop "cambia" en cada render y el memo no sirve)

Las tres trabajan en equipo. El panel de pedidos, arreglado:

import { memo, useCallback, useMemo, useState } from "react";

// 1) React.memo: la fila solo se re-renderiza si SUS props cambian.
const FilaPedido = memo(function FilaPedido({
  pedido, onSeleccionar,
}: {
  pedido: PedidoResumen;
  onSeleccionar: (id: number) => void;
}) {
  return (
    <li onClick={() => onSeleccionar(pedido.id)}>
      Mesa {pedido.mesa} — ${pedido.total} — {pedido.estado}
    </li>
  );
});

export function PanelPedidos({ pedidos }: { pedidos: PedidoResumen[] }) {
  const [filtro, setFiltro] = useState("");
  const [seleccionado, setSeleccionado] = useState<number | null>(null);

  // 2) useMemo: no re-filtrar 500 pedidos si lo que cambió fue otra cosa
  const visibles = useMemo(
    () => pedidos.filter((p) => p.estado.includes(filtro)),
    [pedidos, filtro],
  );

  // 3) useCallback: identidad estable → el memo de FilaPedido funciona.
  //    Sin esto, cada render crearía una función nueva y TODAS las
  //    filas se re-renderizarían de todos modos.
  const onSeleccionar = useCallback((id: number) => setSeleccionado(id), []);

  return (
    <ul>
      {visibles.map((p) => (
        // key estable por identidad del dato (el id del pedido).
        // NUNCA el índice del array: al reordenar o borrar, React
        // confundiría unas filas con otras (estado pegado a la fila
        // equivocada, animaciones fantasma).
        <FilaPedido key={p.id} pedido={p} onSeleccionar={onSeleccionar} />
      ))}
    </ul>
  );
}

No memoices por defecto. Cada useMemo/useCallback tiene costo: memoria, una comparación de dependencias por render, y sobre todo costo de lectura para el siguiente humano. Un botón que renderiza en 0.01 ms no necesita nada. Memoiza cuando el Profiler señale un culpable: listas largas, gráficas, el mapa, cálculos pesados. (React 19 incluye el React Compiler, que automatiza gran parte de esta memoización; entender el mecanismo sigue siendo necesario para saber qué te está resolviendo.)

6.3 Rendimiento estructural: las mejoras que más pesan

Colocación del estado: la optimización número uno

Antes que cualquier memo: pon el estado lo más abajo posible. Si el texto del buscador vive en el componente raíz, cada tecla re-renderiza la app entera; si vive dentro de <Buscador />, cada tecla re-renderiza un input. La mayoría de los problemas de rendimiento de React son estado demasiado arriba, y se arreglan moviendo estado, no memoizando.

El mismo principio aplica al contexto: un Context que contiene { usuario, tema, notificaciones } re-renderiza a todos sus consumidores cuando cambia cualquiera de las tres cosas. Divide en contextos pequeños por razón de cambio, o usa un store con selectores (Zustand) donde cada componente se suscribe solo a la rebanada que le importa:

import { create } from "zustand";

interface PedidosStore {
  pedidos: Record<number, PedidoResumen>;
  recibirDeWs: (p: PedidoResumen) => void;
}

export const usePedidosStore = create<PedidosStore>((set) => ({
  pedidos: {},
  recibirDeWs: (p) =>
    set((s) => ({ pedidos: { ...s.pedidos, [p.id]: p } })),
}));

// Cada componente se suscribe SOLO a lo suyo. Cuando llega un
// mensaje de WS, únicamente se re-renderizan los que miran ese dato:
const pendientes = usePedidosStore(
  (s) => Object.values(s.pedidos).filter((p) => p.estado === "PENDIENTE"),
);

Datos del servidor: TanStack Query

Para todo lo que viene por REST (el menú, los reportes, el detalle de un pedido), TanStack Query (antes React Query) es la pieza estándar: cachea por clave, deduplica peticiones simultáneas, re-valida en segundo plano y te da isLoading/error gratis. La combinación con WebSocket es elegante: el mensaje de WS no trae los datos, solo invalida la caché, y Query re-pide lo fresco por REST — otra vez el patrón del capítulo 1.7:

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCanal } from "../../hooks/useCanal";
import { obtenerPedidosPendientes } from "../../api/pedidos";

export function useCocinaEnVivo() {
  const queryClient = useQueryClient();

  const consulta = useQuery({
    queryKey: ["pedidos", "PENDIENTE"],
    queryFn: obtenerPedidosPendientes,
  });

  useCanal("cocina", (msg) => {
    if (msg.type === "PEDIDO_NUEVO" || msg.type === "PEDIDO_ACTUALIZADO") {
      // no confiamos en el payload del WS como fuente de verdad:
      // invalidamos y Query re-pide el estado real por REST
      queryClient.invalidateQueries({ queryKey: ["pedidos", "PENDIENTE"] });
    }
  });

  return consulta;   // { data, isLoading, error, … }
}

Code splitting: no cargar lo que no se usa

El bundler mete toda tu app en un archivo JS que se descarga y evalúa al abrir. La vista de reportes (con Recharts, ~100 KB) no debería pagar ese costo en la pantalla de login. React.lazy parte el bundle por rutas y carga cada trozo al navegar:

import { lazy, Suspense } from "react";

// cada lazy() se convierte en un archivo separado del bundle:
const Reportes = lazy(() => import("./features/reportes/Reportes"));
const MapaRepartos = lazy(() => import("./features/mapa/MapaRepartos"));

export function App() {
  return (
    <Suspense fallback={<PantallaCargando />}>
      <Rutas>
        <Ruta path="/reportes" elemento={<Reportes />} />
        <Ruta path="/mapa" elemento={<MapaRepartos />} />
      </Rutas>
    </Suspense>
  );
}

Virtualización: listas de miles de filas

El historial de pedidos puede tener miles de filas, pero la pantalla muestra ~15. Virtualizar es renderizar solo las visibles (más un colchón) y reciclarlas al hacer scroll. Con react-window:

import { FixedSizeList } from "react-window";

export function ListaHistorial({ pedidos }: { pedidos: PedidoResumen[] }) {
  return (
    <FixedSizeList
      height={600}            // alto del viewport de la lista
      itemCount={pedidos.length}
      itemSize={52}           // alto de cada fila en px
      width="100%"
    >
      {({ index, style }) => (
        // ¡el style es obligatorio! posiciona la fila virtualizada
        <div style={style}>
          <FilaPedido pedido={pedidos[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

El resto del arsenal, en una tabla

TécnicaProblema que resuelve
Debounce en buscadores (300 ms)No disparar una petición por cada tecla; espera a que el usuario pause
Agrupar mensajes de WS (throttle)Si llegan 20 ubicaciones/segundo, acumúlalas y aplica un solo setState cada 500 ms
useTransitionMarcar actualizaciones como no urgentes: el tecleo va primero, el filtrado de la lista grande después
Imágenes con tamaño correcto + loading="lazy"Fotos del menú de 4 MB descargadas para mostrarse en 80×80 px
Estados derivados, no duplicadosSi total se calcula de items, calcúlalo en el render (o useMemo); guardarlo en su propio useState garantiza que un día estén desincronizados
Efectos mínimosCasi todo lo que hoy pondrías en useEffect es o estado derivado (calcúlalo) o un manejador de evento (muévelo ahí). Los efectos son para sincronizar con sistemas externos: WS, mapas, timers

6.4 Arquitecturas de frontend

"Arquitectura de frontend" responde una pregunta concreta: ¿en qué carpeta va cada archivo, y quién puede importar a quién? Las escuelas principales:

Por tipo técnico (la que hay que abandonar)

src/
├── components/     ← 40 componentes de todas las features revueltos
├── hooks/          ← useCanal junto a useCarrito junto a useTema…
├── utils/          ← el cajón de los calcetines sueltos
└── api/

Funciona hasta ~15 archivos. Después, tocar "pedidos" exige saltar entre cuatro carpetas, y nada te dice qué componentes pertenecen a qué pantalla. Es la organización de los tutoriales, no la de las apps que crecen.

Por feature (la recomendada para tu app)

src/
├── app/                  arranque: rutas, providers, tema
├── features/
│   ├── caja/             BotonImprimir, PantallaCobro, imprimir.ts
│   ├── cocina/           PantallaKDS, TarjetaPedido, useCocinaEnVivo
│   ├── mapa/             MapaRepartos, MarcadorRepartidor
│   ├── reportes/         VentasSemana, TopPlatillos
│   ├── llamadas/         AvisoLlamada
│   └── distribucion/     QrApk
├── shared/
│   ├── api/              cliente.ts (fetch + token), pedidos.ts, reportes.ts
│   ├── hooks/            useCanal (lo usan 4 features → es compartido)
│   ├── ui/               Boton, Tarjeta, Modal (sin lógica de negocio)
│   └── types/            pedido.ts, platillo.ts (los DTOs del cap. 1.6)
└── stores/               usePedidosStore, useSesionStore

Las reglas que la mantienen sana — son las mismas ideas del backend por capas del capítulo 3.1, giradas 90°:

Las otras escuelas, para que las reconozcas

ArquitecturaIdea centralCuándo tiene sentido
Feature-Sliced Design (FSD)La versión formalizada de "por feature": capas estrictas app → pages → widgets → features → entities → shared, cada una solo importa hacia abajoEquipos grandes que quieren la regla de imports escrita y linteada; para una persona es más ceremonia de la necesaria
Atomic DesignClasificar la UI en átomos (botón) → moléculas (campo de búsqueda) → organismos (barra superior) → plantillas → páginasPara construir el design system de shared/ui; no dice nada sobre lógica, estado ni datos — complementa, no compite
Container / PresentationalSeparar componentes "listos" (traen datos) de "tontos" (solo pintan props)Patrón histórico (era de las clases); los hooks lo disolvieron — hoy sobrevive como buen instinto: extrae la lógica a un hook (useCocinaEnVivo) y deja el componente pintando
Clean / Hexagonal en frontEl dominio (reglas, cálculos) no conoce React ni fetch; la UI y las APIs son "adaptadores" reemplazablesApps con lógica de negocio pesada en el cliente (editores, cotizadores). En la tuya el dominio vive en el backend — aplícala en miniatura: funciones puras en lib/, testeables sin React

6.5 El ticket de salida

CHECKLIST DE RENDIMIENTO [✔] Profiler ANTES de optimizar [✔] Estado lo más abajo posible [✔] key = id del dato, nunca el índice [✔] memo/useMemo/useCallback solo donde duele [✔] Contexto partido o store con selectores [✔] Servidor → TanStack Query; WS solo invalida [✔] lazy() por ruta; Recharts fuera del login [✔] Listas > 100 filas → react-window [✔] Buscadores con debounce; WS con throttle [✔] Carpetas por feature; shared no importa arriba *** FIN DE LA GUÍA — A COCINAR ***