wandres.dev
RENDIMIENTO · evitar re-renders

Memoización: createSelector, selectores compartidos y factories

Memoizar no es envolver en createSelector: es administrar una caché con un tamaño, una tasa de aciertos y un ciclo de vida. Esta lección estudia la memoización desde ese ángulo aritmético. Analiza qué destruye la tasa de aciertos —entradas inestables, selectores creados dentro del render, argumentos que alternan—, expone el problema clásico de compartir un selector parametrizado entre varios componentes vivos a la vez, desarrolla el patrón factory que lo resolvía junto a useMemo, y sitúa el cambio de reselect 5 al memoizador por WeakMap que vuelve superfluo el patrón en la mayoría de los casos, sin volverlo inútil en los que aún importa.

⏱ 18 min

La lección anterior terminó recetando memoización para las derivaciones, pero esa receta es peligrosa si se toma como un gesto mecánico. Memoizar es introducir una caché en el camino de lectura, y una caché no es una mejora automática: es una estructura con tamaño, política de reemplazo, tasa de aciertos y ciclo de vida. Cuando acierta, cambia un cálculo caro por una comparación barata y conserva la identidad del resultado. Cuando falla, hace el mismo trabajo de antes más la contabilidad de haberlo intentado, ocupa memoria y añade una fuente de bugs sutiles. La diferencia entre una memoización que rinde y una que solo decora está en variables medibles, y aprender a razonar sobre ellas es lo que separa envolver en createSelector de optimizar de verdad.

🎯 Al terminar esta lección sabrás
  • Razonar sobre un selector memoizado como una caché con tasa de aciertos y ciclo de vida.
  • Identificar los tres asesinos de la tasa de aciertos y corregirlos en el orden correcto.
  • Entender el problema de compartir un selector parametrizado entre componentes simultáneos.
  • Aplicar el patrón factory con useMemo y decidir si el memoizador por defecto ya lo hace innecesario.

La aritmética de una caché de lectura

Un selector memoizado hace dos promesas distintas que conviene no mezclar. La primera es que no recalculará mientras sus entradas conserven su identidad, lo que ahorra tiempo de cálculo. La segunda es que devolverá la referencia anterior en ese caso, lo que ahorra renders. La segunda promesa es casi siempre la valiosa: la mayoría de las derivaciones de una aplicación real son baratas de calcular y carísimas en consecuencias, porque una referencia nueva propaga renders por todo un subárbol.

💰

Memoizar paga

Derivación real —filtrar, ordenar, agrupar, agregar— sobre entradas estables, consumida por varios componentes o por uno que gobierna un subárbol grande.

🪙

Memoizar es neutro

Derivación barata leída por un único componente pequeño. Ganas estabilidad de referencia, que ya es motivo suficiente, pero no esperes ver un cambio en los tiempos.

🧾

Memoizar es deuda

Acceso directo a una propiedad que ya era estable. Añades una caché, una comparación por despacho y una indirección a cambio de exactamente nada.

💣

Memoizar engaña

Entradas inestables o instancia recreada en cada render. La forma dice memoizado, el comportamiento es recomputación permanente más la contabilidad de intentarlo.

De ahí se sigue una forma útil de estimar si una memoización merece la pena. El beneficio es la tasa de aciertos multiplicada por el coste de lo que se evita: el cálculo más el subárbol que se habría reconciliado. El coste es la comparación de entradas en cada despacho, la memoria retenida y la complejidad añadida. Una memoización con tasa de aciertos alta sobre un subárbol grande es una de las mejores inversiones disponibles; una con tasa cercana a cero es puro pasivo, y además uno que se ve bien en la revisión de código.

⚠️
Los tres asesinos de la tasa de aciertos

Una caché de lectura falla siempre por una de estas tres razones, y conviene descartarlas en este orden. Primera: entradas inestables, cuando algún selector de entrada construye un objeto y por tanto ninguna comparación coincide jamás. Segunda: el selector creado dentro del render, cuando se llama a createSelector en el cuerpo del componente y en cada render nace una instancia con la caché vacía. Tercera: argumentos que alternan, cuando varios consumidores llaman al mismo selector con parámetros distintos y se turnan para desalojarse. Las tres producen el mismo síntoma —recomputación permanente— pero la reparación es distinta en cada caso, así que diagnosticarlas por separado no es pedantería.

// ASESINO 1: la entrada construye. Ninguna clave coincide nunca.
const malo = createSelector(
  [(s: RootState) => ({ ...s.filtros })],  // referencia nueva en cada llamada
  (filtros) => aplicar(filtros),
)

// ASESINO 2: instancia nueva por render. La cache nace vacia cada vez.
function Panel() {
  const sel = createSelector([selectItems], (items) => items.filter(vigente))
  return <Lista items={useSelector(sel)} />
}

// Correcto: entradas estrechas y estables, instancia definida una sola vez.
const selectVigentes = createSelector(
  [(s: RootState) => s.items, (s: RootState) => s.filtros.estado],
  (items, estado) => items.filter((i) => i.estado === estado),
)

El segundo caso merece énfasis porque es el que más se disfraza de código correcto. Un selector definido en el módulo tiene una caché cuya vida es la del módulo, compartida por toda la aplicación; un selector definido dentro del componente tiene una caché cuya vida es la de un render, es decir, ninguna. La memoización no está en la función createSelector, está en la instancia que devuelve, y donde vive esa instancia determina qué puede recordar.

Compartir un selector parametrizado

Llegamos al problema que dio origen a uno de los patrones más citados del ecosistema. Un selector con parámetro —dame la tarea con este identificador, dame el total de esta categoría— es indispensable en listas. Con el memoizador clásico, cuya caché recuerda una única combinación de entradas, ese selector compartido por veinte filas con veinte identificadores distintos se comporta de la peor forma imaginable: cada llamada desaloja la entrada de la anterior y ninguna acierta jamás. La caché no solo no ayuda; garantiza recomputar veinte veces por despacho más la contabilidad.

flowchart TD
D[un despacho] --> F1[fila 1 pide id A]
F1 --> C1[cache guarda A y desaloja lo anterior]
C1 --> F2[fila 2 pide id B]
F2 --> C2[cache guarda B y desaloja A]
C2 --> F3[fila 3 pide id C]
F3 --> C3[cache guarda C y desaloja B]
C3 --> R[cero aciertos con cache de tamano uno]
style R fill:#f38ba8,color:#11111b

La respuesta idiomática fue la factory: en lugar de exportar un selector, se exporta una función que fabrica selectores, y cada componente crea el suyo con useMemo para que sobreviva a sus renders y muera con él. Así cada consumidor tiene su propia caché de tamaño uno, siempre consultada con el mismo argumento, y la tasa de aciertos vuelve a ser máxima.

// La factory: una funcion que fabrica instancias, no una instancia.
export const makeSelectTotalPorCategoria = () =>
  createSelector(
    [(s: RootState) => s.items, (_s: RootState, cat: string) => cat],
    (items, cat) => items.filter((i) => i.cat === cat).length,
  )

function Categoria({ cat }: { cat: string }) {
  // una instancia por componente, estable durante toda su vida
  const selectTotal = useMemo(makeSelectTotalPorCategoria, [])
  const total = useSelector((s: RootState) => selectTotal(s, cat))
  return <Badge n={total} />
}

Antes de aplicar la factory conviene comprobar que el problema es realmente el que parece, porque el mismo síntoma tiene dos causas muy distintas y solo una se arregla así. Si las filas piden identificadores distintos y se turnan, la causa es el desalojo mutuo y la factory lo resuelve dando una caché a cada una. Si todas piden lo mismo y aun así se recomputa siempre, el problema no es el tamaño de la caché sino la estabilidad de las entradas, y fabricar instancias solo multiplicará el número de cachés que fallan.

Desde reselect 5, incluido en las versiones actuales de Redux Toolkit, el memoizador por defecto cachea por identidad de las entradas en WeakMap anidados, con capacidad efectivamente ilimitada y liberación automática por el recolector de basura. Eso desactiva el tercer asesino en el caso habitual: el mismo selector compartido por veinte filas mantiene veinte resultados memoizados, uno por identificador, sin que tú fabriques nada. La factory dejó de ser obligatoria para el escenario que la hizo famosa.

ℹ️
Por qué el patrón sigue mereciendo estudio

Que el defecto haya vuelto superflua la factory en el caso común no la convierte en folclore. Sigue siendo la respuesta cuando quieres que la caché muera con el componente en lugar de vivir mientras vivan sus claves, cuando necesitas un memoizador acotado con política de reemplazo explícita para poner un techo a la memoria, y cuando el selector guarda estado auxiliar propio de un consumidor. Además, la vas a encontrar en todo el código escrito antes de 2024, y leer código ajeno con la explicación correcta en la cabeza es la mitad del oficio. La conclusión sensata no es descartarla, sino dejar de aplicarla por reflejo.

Componer sin destruir la caché

La memoización compone, y esa es su propiedad más infravalorada: un selector memoizado puede ser entrada de otro, formando un grafo de derivaciones donde cada nodo recuerda su parte. Un cambio en una hoja recorre hacia arriba solo la rama afectada, y los demás nodos devuelven su valor cacheado sin ejecutar nada. Bien construido, ese grafo hace que el coste total por despacho sea proporcional a lo que de verdad cambió, que es exactamente lo que el store global no sabía hacer por su cuenta.

// Composicion: cada nivel memoiza su parte y solo recomputa su rama.
const selectVisibles = createSelector([selectTodos, selectFiltro], filtrar)
const selectOrdenados = createSelector([selectVisibles, selectOrden], ordenar)
const selectPaginados = createSelector([selectOrdenados, selectPagina], paginar)

Esa propiedad tiene una consecuencia de diseño que conviene explotar: conviene que los selectores de entrada sean lo más pequeños y compartidos posible. Un selector hoja que devuelve una rama del estado y se reutiliza en diez derivaciones distintas hace que las diez compartan la misma comparación y acierten o fallen juntas de forma predecible. Multiplicar funciones anónimas equivalentes como entradas, en cambio, dispersa la caché sin ningún beneficio y hace ilegible el grafo.

Pero componer también multiplica las oportunidades de romper la cadena, porque basta un nodo inestable para que todos los de arriba fallen. Si selectVisibles devuelve un array nuevo en cada despacho por culpa de una entrada mal elegida, selectOrdenados y selectPaginados recomputan siempre por mucho que estén memoizados. Una cadena de cachés no es más fuerte que su eslabón menos estable, y por eso las reparaciones deben aplicarse siempre de abajo arriba: estabiliza primero las hojas, y los niveles superiores se curan solos.

Nada de esto se juzga a ojo. La tasa de aciertos de un selector es directamente observable con tres líneas, y la cifra suele desmentir la intuición: cachés que se creían útiles resultan tener cero aciertos, y derivaciones que parecían triviales concentran la mitad del trabajo de lectura de la pantalla.

// Instrumentacion minima: cuantas veces recomputa de verdad.
let recomputos = 0
const selectCaro = createSelector([selectA, selectB], (a, b) => {
  recomputos += 1
  return derivar(a, b)
})
// Comparar recomputos contra el numero de despachos revela la tasa de aciertos.
Memoizar es decidir quién posee el tiempo de vida de un resultado

La discusión sobre cachés en selectores parece técnica y menor, pero debajo hay una decisión arquitectónica que nadie enuncia: cuando memoizas, estás declarando cuánto tiempo debe existir un valor derivado y quién manda sobre esa duración. Un selector definido en un módulo dice que su resultado pertenece a la aplicación y vivirá mientras la aplicación viva. Una factory con useMemo dice que pertenece a un componente y morirá con él. El memoizador por WeakMap dice algo más sutil y más elegante: que el resultado pertenece a sus entradas y vivirá exactamente mientras esas entradas sigan siendo alcanzables por alguien, de modo que la vida de la caché se ata a la vida de los datos en lugar de a la de un módulo o a la de un nodo del árbol. Vistas así, las opciones dejan de ser tres implementaciones intercambiables y se revelan como tres respuestas distintas a la pregunta de quién es el dueño de este valor derivado, que es la misma pregunta que gobierna las fronteras entre capas de estado en toda la arquitectura. Y de ahí se deduce por qué envolver todo en createSelector sin pensar es un error y no una precaución: no estás añadiendo velocidad, estás creando dueños. Cada caché que introduces es un fragmento de estado implícito, no declarado, que retiene memoria, puede quedar obsoleto respecto a lo que representa y complica razonar sobre cuándo se ejecuta el código de verdad. La regla que sobrevive a todas las versiones de todas las librerías es que la memoización es la respuesta correcta cuando existe una derivación genuina, con entradas estables y consumidores repetidos, y es deuda disfrazada cuando se aplica a un acceso barato que ya era estable. Antes de memoizar, la pregunta no es cuánto tarda esto, sino cuánto debería durar su resultado y quién debería mandar sobre esa duración.

⚔️ Audita tus cachés de lectura
  1. Haz inventario de todos los createSelector de tu proyecto y anota, para cada uno, dónde vive su instancia: módulo, componente o factory.
  2. Instrumenta tres de ellos con un contador dentro de la función de resultado y mide su tasa de aciertos durante una interacción real.
  3. Encuentra al menos un caso de cada asesino: entrada inestable, selector creado en el render y argumentos que alternan.
  4. Repara el selector creado en el render moviéndolo al módulo o envolviéndolo en useMemo, y compara la tasa de aciertos antes y después.
  5. Construye un selector parametrizado compartido por diez filas y comprueba si el memoizador por defecto ya mantiene diez resultados vivos sin factory.
  6. Toma una cadena de tres selectores compuestos, rompe a propósito la estabilidad del primero y observa cómo se anula la memoización de los otros dos.