wandres.dev
DERIVACIÓN · memos y selectores

Selectores: derivar leyendo el store

Un selector es una función pura que lee el estado de un store y devuelve un valor derivado, encapsulando la forma del estado y transformándolo. El problema que resuelve reselect es la memoización por referencia de entrada: el result fn solo se recalcula cuando cambia la salida de un selector de entrada, y devuelve la misma referencia si nada cambió, cortando re-renders. La cache de tamaño uno, el problema de las listas y el weakMapMemoize de Reselect 5.

⏱ 18 min

Cuando el estado vive en un store centralizado —Redux, Zustand, Jotai— la derivación adopta una forma con nombre propio: el selector. Un selector es una función pura que recibe el estado del store y devuelve un valor calculado a partir de él, y hace dos trabajos a la vez: encapsula la forma del estado, de modo que los componentes no dependan de cómo está estructurado por dentro, y deriva de él la vista que cada componente necesita. Pero un selector ingenuo tiene un defecto que en Redux se paga caro: si transforma el estado y devuelve un objeto o un array nuevos, entrega una referencia distinta en cada evaluación, y como los bindings de UI comparan la salida por referencia, el componente se re-renderiza aunque nada relevante haya cambiado. La solución es la memoización por referencia de entrada, y su implementación canónica es reselect.

🎯 Al terminar esta lección sabrás
  • Definir el selector como función pura del store con doble papel: encapsular forma y derivar vista.
  • Explicar el re-render que provoca un selector no memoizado al devolver una referencia nueva.
  • Dominar la memoización por referencia de entrada de reselect: selectores de entrada y result fn.
  • Resolver el problema de la cache de tamaño uno en listas con factorías y con weakMapMemoize.

Qué es un selector y qué problema resuelve

La razón más citada para usar selectores es la encapsulación: si cada componente lee state.usuarios.porId[id] directamente, la forma del store se filtra por toda la aplicación y renombrar un campo obliga a tocar cien archivos. Un selector concentra ese conocimiento en un punto —selectUsuario(state, id)— y deja a los componentes hablar de intenciones, no de rutas. Pero el papel que de verdad exige técnica es el segundo: derivar. Un selector que filtra, ordena o agrega produce datos que no existen tal cual en el store, y ahí aparece el conflicto con el modelo de re-render.

El binding de React con Redux, useSelector, reevalúa el selector después de cada acción despachada y compara su nueva salida con la anterior mediante igualdad por referencia; si difieren, re-renderiza. Un selector en línea que hace state.items.filter(...) construye un array nuevo en cada evaluación, así que la comparación siempre falla y el componente se re-renderiza tras cada acción del store, aunque la lista y el filtro no hayan cambiado en absoluto. El coste no es la derivación en sí, sino la referencia nueva que engaña al mecanismo de igualdad.

// Antipatron: selector en linea que crea una referencia nueva en cada accion
const visibles = useSelector((state) =>
  state.items.filter((i) => i.estado === state.filtro)
);
// react-redux compara por referencia: siempre distinta, re-render en cada dispatch

reselect: memoización por referencia de entrada

reselect descompone un selector derivado en dos piezas. Los selectores de entrada son extractores baratos y estables que devuelven porciones crudas del store —los items, el filtro— sin transformarlas. El result fn recibe esas porciones y hace el trabajo caro de derivar. La clave está en cuándo se ejecuta el result fn: createSelector recuerda la última tanda de salidas de los selectores de entrada y, en cada llamada, las compara por referencia con las nuevas; si todas coinciden, devuelve el resultado cacheado —la misma referencia— sin volver a ejecutar el result fn. Solo cuando alguna entrada cambia por referencia recalcula y produce una referencia nueva.

import { createSelector } from "reselect";

// Selectores de entrada: extractores baratos, sin transformar nada
const selectItems = (state) => state.items;
const selectFiltro = (state) => state.filtro;

// Selector derivado y memoizado: el result fn corre solo si cambia una entrada
export const selectVisibles = createSelector(
  [selectItems, selectFiltro],
  (items, filtro) => items.filter((i) => i.estado === filtro)
);

Esa doble garantía —recomputar solo cuando cambia una entrada, y devolver la misma referencia cuando no— es lo que corta los re-renders: mientras items y filtro conserven su identidad, selectVisibles devuelve el array anterior, useSelector ve la misma referencia y el componente no se re-renderiza. De aquí sale la regla más importante para escribir selectores de entrada: deben ser extractores estables. Si un selector de entrada devuelve él mismo una referencia nueva cada vez —porque filtra o mapea en su cuerpo—, la memoización del result fn se rompe en cada llamada y todo el esfuerzo es en balde.

flowchart TD
ST[store] --> IN[selectores de entrada extraen porciones crudas]
IN --> G{cambio alguna entrada por referencia}
G -->|no| CA[devuelve la misma referencia cacheada]
G -->|si| RF[ejecuta el result fn y deriva]
RF --> NR[nueva referencia cacheada]
CA --> U1[el componente no re-renderiza]
NR --> U2[el componente re-renderiza]
style CA fill:#a6e3a1,color:#11111b
style U1 fill:#a6e3a1,color:#11111b
style RF fill:#fab387,color:#11111b
⚠️
Un selector de entrada que deriva rompe la memoización entera

La causa más frecuente de que un selector memoizado no memoice nada es un selector de entrada que no se limita a extraer. Si escribes como entrada algo que ya filtra o mapea, devolverá una referencia distinta en cada evaluación, la comparación de entradas fallará siempre y el result fn correrá cada vez, con el sobrecoste añadido de la maquinaria de cache. Los selectores de entrada extraen porciones estables del store; el trabajo de derivar pertenece al result fn, y solo a él.

La cache de tamaño uno y el problema de las listas

El memoizador clásico de reselect guarda una sola entrada: recuerda únicamente la última combinación de argumentos. Para un selector global funciona de maravilla, pero se rompe en cuanto el mismo selector se comparte entre muchos elementos de una lista. Si selectTotalDeLinea(state, lineaId) se llama para la línea A y luego para la B, la segunda llamada expulsa la cache de la primera; al volver a la A la cache ya no acierta, y con muchos componentes leyéndolo en el mismo render el memo pasa a fallar sistemáticamente. Es el envés directo de la decisión de tamaño uno que vimos en la memoización: barata y perfecta para un consumidor, inservible para muchos con argumentos distintos.

Hay dos remedios, y en 2026 conviven. El clásico es la factoría de selectores: una función que crea una instancia nueva del selector memoizado por cada componente, de modo que cada uno tiene su propia cache de tamaño uno y ninguno expulsa la del otro. El moderno es cambiar el memoizador: Reselect 5 adoptó weakMapMemoize como memoizador por defecto, una cache de tamaño efectivamente ilimitado que indexa los resultados por sus argumentos mediante WeakMap anidados, sin fugas de memoria porque las entradas se liberan cuando sus claves dejan de existir. Con él, el mismo selector compartido entre mil líneas acierta para las mil sin factorías.

lruMemoize (cache clásica) weakMapMemoize (defecto en Reselect 5)
Tamaño de cache uno, o maxSize fijo efectivamente ilimitado
Selector compartido en lista falla en cada cambio de argumento acierta para cada argumento
Gestión de memoria expulsión por antigüedad liberación por recolección de basura
Cuándo elegirlo un consumidor por selector selectores paramétricos y listas

Este patrón no es exclusivo de Redux. En Zustand, un selector que devuelve un objeto nuevo provoca el mismo re-render, y la solución es useShallow, que compara la salida con igualdad superficial en lugar de por referencia. En Jotai, el problema desaparece por diseño: un átomo derivado es un selector con rastreo automático de dependencias, que solo se reevalúa cuando cambia alguno de los átomos que lee. Redux Toolkit, por su parte, reexporta createSelector, ofrece createDraftSafeSelector para usarlo dentro de reducers y genera selectores ya memoizados con entityAdapter.getSelectors().

// Zustand: useShallow evita el re-render cuando el objeto derivado es equivalente
import { useShallow } from "zustand/react/shallow";
const datos = useStore(useShallow((s) => ({ nombre: s.nombre, email: s.email })));

// Jotai: un atomo derivado ES un selector con rastreo automatico de dependencias
const visiblesAtom = atom(
  (get) => get(itemsAtom).filter((i) => i.estado === get(filtroAtom))
);

Selectores paramétricos y composición

Un selector rara vez vive aislado: recibe argumentos y se apoya en otros selectores. Los selectores paramétricos aceptan, además del estado, un argumento —un id, un filtro— que llega desde las props del componente. Con reselect, ese argumento se propaga a través de un selector de entrada que lo lee, y aquí reaparece el problema de la cache de tamaño uno: si dos componentes piden el mismo selector con id distintos, se pisan la cache mutuamente, salvo que uses una factoría por instancia o el weakMapMemoize de Reselect 5.

// Selector parametrico: el segundo argumento llega desde las props
const selectPorId = createSelector(
  [selectItems, (_state, id) => id], // un selector de entrada extrae el argumento
  (items, id) => items.find((i) => i.id === id) // el result fn lo usa para derivar
);

La otra dimensión es la composición: como un selector es una función pura del estado, puede servir de selector de entrada para otro selector, y así se construye el grafo de derivación de la lección siguiente sin salir de Redux. Cada eslabón memoiza su parte, de modo que un cambio en el estado solo recomputa los selectores cuyas entradas cambiaron, y el trabajo se comparte entre todos los consumidores del grafo.

// Composicion: la salida de un selector memoizado alimenta a otro
const selectVisibles = createSelector([selectItems, selectFiltro], filtrar);
const selectTotal = createSelector([selectVisibles], sumarPrecios);
const selectResumen = createSelector(
  [selectVisibles, selectTotal],
  (visibles, total) => ({ cuantos: visibles.length, total })
);

Redux Toolkit industrializa este patrón. createEntityAdapter genera un conjunto de selectores memoizados sobre un estado normalizado, y createDraftSafeSelector permite reutilizar la misma lógica de derivación dentro de un reducer, donde el estado es un borrador mutable de Immer. La consecuencia práctica es que casi nunca escribes la memoización a mano: la declaras componiendo piezas que ya la traen incorporada.

Utilidad de Redux Toolkit Qué genera
createEntityAdapter().getSelectors() selectAll, selectById y selectIds ya memoizados
createSelector un selector derivado con memoización por entrada
createDraftSafeSelector un selector reutilizable dentro de un reducer de Immer
📝
Un selector paramétrico es una familia de derivaciones, no una sola

Cuando un selector recibe un argumento, deja de representar una derivación y pasa a representar una por cada valor del argumento. Esa es la raíz del problema de la cache de tamaño uno en listas y la razón de que Reselect 5 adoptara un memoizador indexado por argumentos: una familia de derivaciones necesita una familia de caches, no una sola. Pensar el selector paramétrico como una función de dos entradas —el estado y el argumento— aclara de inmediato por qué su memoización es más delicada.

Un selector no lee el estado: define una frontera y una identidad

El selector parece una utilidad menor —una función que saca datos de un store— pero encierra dos ideas que gobiernan toda la reactividad basada en stores. La primera es que un selector es una frontera de encapsulación: convierte la forma interna del estado en un contrato de lectura, de modo que el store puede reorganizarse por dentro sin que ningún componente se entere, porque todos hablan con la intención que el selector expresa y no con la ruta física del dato. Esa indirección, que parece burocracia, es lo que permite que el estado evolucione sin arrastrar a cien consumidores con él. La segunda idea es más profunda y es la que de verdad exige oficio: en un mundo donde el cambio se detecta comparando referencias, un selector no solo devuelve un valor, administra una identidad. Devolver la misma referencia cuando nada ha cambiado no es una optimización opcional, es la afirmación de que nada ha cambiado, y esa afirmación es la que decide si media aplicación se re-renderiza o no tras cada acción. Por eso la memoización de selectores no va de velocidad sino de verdad referencial: el result fn de reselect promete que dos vistas derivadas de entradas idénticas serán la misma cosa, no dos cosas iguales, y sobre esa promesa se apoya todo el sistema de re-render para no hacer trabajo inútil. Comprender un selector es, entonces, comprender que en estas arquitecturas la pregunta cambió es realmente la pregunta es la misma referencia, y que tu deber al derivar del store es preservar la identidad de lo que no cambia con el mismo cuidado con que calculas lo que sí. Quien interioriza esto deja de espolvorear createSelector por superstición y empieza a diseñar la frontera de lectura de su estado: qué se extrae, qué se deriva, y dónde vive la cache que sostiene la identidad.

⚔️ Diseña la frontera de lectura de tu store
  1. Encuentra un useSelector en línea que devuelva un array o un objeto derivado y demuestra con el perfilador que re-renderiza tras cada acción, aunque su resultado sea equivalente.
  2. Refactorízalo con createSelector, separando extractores de entrada baratos del result fn que deriva, y confirma que los re-renders desaparecen.
  3. Introduce a propósito un selector de entrada que filtre en su cuerpo y observa cómo rompe la memoización del selector completo.
  4. Comparte un selector paramétrico entre los elementos de una lista y comprueba que la cache de tamaño uno falla; resuélvelo con una factoría y luego con weakMapMemoize.
  5. Reescribe la misma derivación como un átomo derivado de Jotai y contrasta cuánto de la ceremonia de memoización desaparece cuando el rastreo es automático.