wandres.dev
SELECTORES · reselect y normalización

Patrones de datos derivados: componer sin recomputar

El cierre del nivel une las cuatro lecciones en un modo de pensar: la interfaz es una función pura del estado normalizado, calculada a través de un grafo de selectores memoizados que se componen unos sobre otros. Veremos cómo componer selectores en cadenas y estructuras, cómo diseñar entradas granulares para no recomputar, por qué derivar siempre gana a guardar datos derivados, y —lo más difícil— el equilibrio con la simplicidad: cuándo un selector merece createSelector y cuándo memoizarlo es complejidad sin beneficio. El mismo criterio que atraviesa todo el track, aplicado a la última milla de la lectura del estado.

⏱ 18 min

Las cuatro lecciones anteriores fueron piezas; esta las ensambla en una imagen única. Un estado normalizado guarda la verdad una sola vez; unos selectores base la leen preservando referencias; unos selectores derivados y memoizados computan sobre ellos las proyecciones que la interfaz necesita; y esos derivados se componen entre sí formando un grafo de dependencias que convierte la interfaz en una función pura del estado. Es el ideal de la programación funcional-reactiva llevado a la gestión de estado: no guardas lo que puedes calcular, y calculas a través de una red de derivaciones que solo recomputa lo que un cambio real toca. Pero un ideal llevado sin criterio se vuelve dogma, y la parte más valiosa de esta lección no es cómo componer selectores, sino cuándo no hacerlo: cuándo memoizar es sabiduría y cuándo es complejidad disfrazada de rigor.

🎯 Al terminar esta lección sabrás
  • Componer selectores en cadenas y estructuras, formando un grafo explícito de derivaciones.
  • Diseñar entradas granulares para minimizar la recomputación y preservar referencias.
  • Justificar por qué derivar gana a guardar datos derivados como segunda fuente de verdad.
  • Aplicar un criterio para decidir cuándo un selector merece memoización y cuándo no.

Componer selectores en un grafo

La verdadera potencia de createSelector no está en un selector aislado, sino en que la salida de uno puede ser la entrada de otro. Así se construyen cadenas de derivación —el estado derivado en cascada del Nivel 6— donde cada eslabón memoiza su parte y solo recomputa cuando su entrada concreta cambia. El grafo resultante se recorre en orden topológico y es glitch-free por construcción (Nivel 8): un cambio se propaga por las aristas que dependen de él y por ninguna más.

// Cada derivado se apoya en el anterior: un grafo de dependencias.
const selectVisibles = createSelector(
  [selectTareas, selectFiltro],
  (tareas, filtro) => tareas.filter((t) => t.estado === filtro),
)

const selectVisiblesOrdenadas = createSelector(
  [selectVisibles, selectOrden],           // reutiliza el derivado de arriba
  (visibles, orden) => [...visibles].sort(orden),
)

// createStructuredSelector: agrupa varios en un objeto de resultado.
const selectVistaLista = createStructuredSelector({
  items: selectVisiblesOrdenadas,
  total: selectNumTareas,
})
flowchart TD
E1[selectTareas] --> D1[selectVisibles]
F1[selectFiltro] --> D1
D1 --> D2[selectVisiblesOrdenadas]
O1[selectOrden] --> D2
D2 --> UI[componente lista]
style D1 fill:#89b4fa,color:#11111b
style D2 fill:#cba6f7,color:#11111b
style UI fill:#a6e3a1,color:#11111b

Componer así tiene una ventaja de diseño más allá del rendimiento: cada selector es pequeño, tiene un solo propósito, se prueba en aislamiento y se reutiliza. Si diez vistas necesitan las tareas visibles, todas parten del mismo selectVisibles y comparten su caché.

Evitar recomputar: la granularidad de las entradas

Un grafo de selectores solo es eficiente si sus entradas están bien diseñadas. La regla, ya insinuada en la lección 2, se vuelve central aquí: cada selector debe recibir las entradas más estrechas que su cálculo necesita, y ni una más. Un selector cuya función de resultado depende de entities pero recibe como entrada todo el slice recomputará cuando cambie cualquier campo del slice, aunque entities no se haya tocado. Y el antipatrón más costoso es la entrada que fabrica una referencia nueva en cada llamada, que ya vimos: rompe la memoización de raíz.

// Antipatron: la entrada crea un objeto nuevo cada vez ->
// nunca acierta en cache y la funcion de resultado corre siempre.
const malo = createSelector(
  [(s: RootState) => ({ a: s.a, b: s.b })], // referencia nueva en cada llamada
  (par) => calcular(par.a, par.b),
)

// Correcto: entradas estrechas y estables, cada una su propio selector.
const bueno = createSelector(
  [(s: RootState) => s.a, (s: RootState) => s.b],
  (a, b) => calcular(a, b),
)

Aquí es donde la normalización de la lección 3 rinde su dividendo final: como los updates son locales, las ramas no tocadas conservan su referencia, y un grafo de selectores bien diseñado sobre estado normalizado recomputa asombrosamente poco, porque casi todas sus entradas siguen siendo idénticas casi siempre.

Derivar, no guardar

Hay una tentación recurrente: en lugar de derivar un valor con un selector, guardarlo ya calculado en el store —el total del carrito, la lista filtrada, el contador—. Es casi siempre un error, y la razón conecta con el corazón del track: guardar datos derivados crea una segunda fuente de verdad que hay que mantener sincronizada con la primera, y cada punto de sincronización es un bug esperando a que alguien actualice una y olvide la otra. Un dato derivado por un selector, en cambio, es incapaz de desincronizarse: se recalcula de la fuente cada vez que la fuente cambia, y su corrección es una propiedad de la función, no una responsabilidad del programador.

⚠️
La excepción que confirma la regla

Derivar en vez de guardar es la regla, pero no un absoluto. Si una derivación es genuinamente costosa —un cálculo pesado sobre decenas de miles de filas que ni la memoización salva porque las entradas cambian a menudo— y su resultado se consume en un camino crítico, precomputarlo y guardarlo puede justificarse. Pero es una decisión de rendimiento medida, no un atajo por comodidad, y quien la toma asume el contrato de mantener esa copia coherente. En 2026, antes de guardar un derivado, pregúntate si el problema no era en realidad estado de servidor mal ubicado: mucho de lo que la gente cachea a mano en el store lo resuelve mejor TanStack Query o RTK Query, que ya gestionan la coherencia de su caché.

El equilibrio con la simplicidad

Y llegamos a la lección que separa al que conoce reselect del que lo domina: no todo selector merece createSelector. La memoización tiene un coste —cierres, cachés, comparaciones en cada llamada— y envolver en createSelector una lectura trivial o un cálculo barato que devuelve un primitivo añade complejidad sin comprar nada. Un selector state => state.usuario.nombre no necesita memoización: devuelve un string, ya estable por valor. Memoizar por reflejo es tan error como no memoizar por descuido; ambos nacen de aplicar una regla sin entender qué problema resuelve.

💡
El criterio, en tres preguntas

Alcanza createSelector cuando se cumple al menos una de estas tres, y déjalo plano si no se cumple ninguna. Una: ¿el cálculo es caro? Ordenar, agrupar, agregar sobre muchos elementos. Dos: ¿devuelve una referencia nueva —array u objeto— que consume useSelector o un componente memoizado? Entonces la memoización estabiliza esa referencia y evita renders. Tres: ¿se reutiliza en muchos sitios que se beneficiarían de compartir caché? Si la respuesta a las tres es no —una lectura escalar, barata, de un solo uso—, un selector plano state => state.x es más simple, más rápido de leer y más honesto. La madurez no es memoizar todo; es saber qué aristas del grafo merecen una caché y cuáles son ruido.

La interfaz como función pura del estado, con criterio en cada arista

El destino al que apunta todo este nivel es una idea de una elegancia casi matemática: la interfaz de usuario es una función pura del estado, UI = f(estado), donde estado está normalizado como una base de datos en miniatura y f es un grafo de selectores memoizados que se componen para producir, de la verdad mínima guardada, cada proyección que la pantalla necesita. En ese modelo no existen datos derivados que puedan desincronizarse, porque no se guardan; no existen recomputaciones inútiles, porque cada nodo del grafo memoiza su parte; y no existe acoplamiento a la forma del estado, porque los selectores son la frontera que la encapsula. Es el ideal funcional-reactivo, el mismo que persiguen los signals con su tracking automático y los observables con sus operadores, alcanzado aquí con funciones puras explícitas y memoización deliberada. Pero el ingeniero maduro sabe que un ideal aplicado sin juicio se degrada en dogma, y que el grafo perfecto sobre el papel puede ser, en el código, una maraña de createSelector que envuelven cálculos triviales, que oscurecen más de lo que optimizan, que hacen pagar la complejidad de la memoización allí donde no había nada que memoizar. Por eso la destreza final de este nivel no es construir el grafo más completo, sino el grafo justo: normalizar lo que es una entidad y dejar plano lo que no lo es, memoizar la arista cara o inestable y dejar directa la barata y escalar, derivar la verdad que puede calcularse y guardar solo la que de verdad no puede. Es exactamente el mismo criterio que recorrió el track entero —minimizar y aislar el estado, elegir la herramienta que ajusta a la forma del problema y no al revés— aplicado ahora a la última milla, la de la lectura. Dominar los selectores no es, al final, saber usar reselect; es tener el juicio de reconocer, en cada punto donde el estado se lee, cuánta maquinaria merece de verdad ese punto, y ni una pieza más.

⚔️ Construye el grafo justo
  1. Toma una vista de tu app y dibuja el grafo de selectores que la alimenta, desde los base normalizados hasta el derivado que consume el componente.
  2. Audita las entradas de cada createSelector: estrecha las que reciban de más y repara cualquiera que fabrique una referencia nueva en cada llamada.
  3. Busca en tu store un dato derivado que estés guardando y conviértelo en un selector; documenta el punto de sincronización —el bug— que acabas de eliminar.
  4. Encuentra un createSelector que envuelva una lectura escalar trivial y quítalo: mide que el resultado es más simple sin coste de rendimiento.
  5. Aplica las tres preguntas del criterio a cinco lecturas de tu app y clasifícalas en ‘memoizar’ o ‘dejar plano’, justificando cada decisión con la pregunta que la disparó.
  6. Conecta este grafo con el resto del track: señala dónde aparecen la fuente única de verdad (Nivel 1), la memoización (Nivel 6) y la propagación glitch-free (Nivel 8) dentro de tu diseño de selectores.