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:
- 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. - 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.
- 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
| Herramienta | Qué memoriza | Cuá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 renders | Cá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 renders | Funciones 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écnica | Problema 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 |
useTransition | Marcar 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 duplicados | Si 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ínimos | Casi 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°:
- Una feature puede importar de
shared/;shared/jamás importa de una feature (si lo necesita, ese código no era compartido). - Las features no se importan entre sí. Si cocina necesita algo de caja, eso que necesitan ambas baja a
shared/. - Algo se mueve a
shared/cuando la segunda feature lo necesita, no antes (no generalices por si acaso).
Las otras escuelas, para que las reconozcas
| Arquitectura | Idea central | Cuá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 abajo | Equipos grandes que quieren la regla de imports escrita y linteada; para una persona es más ceremonia de la necesaria |
| Atomic Design | Clasificar la UI en átomos (botón) → moléculas (campo de búsqueda) → organismos (barra superior) → plantillas → páginas | Para construir el design system de shared/ui; no dice nada sobre lógica, estado ni datos — complementa, no compite |
| Container / Presentational | Separar 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 front | El dominio (reglas, cálculos) no conoce React ni fetch; la UI y las APIs son "adaptadores" reemplazables | Apps 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 |