Selectores: la frontera de lectura del estado
Un reducer decide cómo cambia el estado; un selector decide cómo se lee. Esta lección presenta el selector como la mitad de lectura de la API del store: una función pura de estado a valor derivado que encapsula la forma interna y desacopla de ella a los componentes. Recorreremos la simetría reducer–selector, la pureza que hace al selector componible y comprobable, por qué leer la forma cruda acopla y fragiliza, la anatomía de selectores base y derivados, dónde viven y cómo se re-basan sobre el estado raíz, y la grieta —una referencia nueva en cada lectura— que obligará a memoizar en la lección siguiente.
Cada store guarda su estado con una forma concreta: qué campos hay, cómo se anidan, qué vive bajo qué clave. Esa forma es una decisión de implementación, y como toda decisión de implementación, cambiará. El selector es la herramienta que impide que ese cambio se propague por toda la aplicación: una función pura que recibe el estado y devuelve exactamente el dato que un consumidor necesita, ya derivado. Si los reducers son la única puerta autorizada para escribir, los selectores son la única puerta autorizada para leer; entre ambos encierran la forma del store tras una frontera, y del otro lado los componentes dejan de saber —y de depender de— cómo están guardadas las cosas.
- Definir el selector como función pura de estado a valor y entender su simetría con el reducer.
- Reconocer cómo el acceso directo a la forma del store acopla los componentes y por qué eso fragiliza.
- Distinguir selectores base de selectores derivados y ver la grieta de la identidad referencial.
- Saber dónde colocar, cómo nombrar y cuándo re-basar los selectores sobre el estado raíz.
El selector como frontera de lectura
Un selector es, en su forma más pura, una función (estado) => valor. Nada más: no muta, no despacha, no tiene efectos; dado el mismo estado devuelve el mismo resultado. Esa modestia es justo lo que lo hace potente. El reducer y el selector forman una simetría exacta —el reducer es la escritura, el selector es la lectura— y juntos cierran el estado entre dos funciones puras que constituyen su API. Es la misma intuición que separa comandos de consultas en CQRS, traída a la escala de un store de cliente: quien escribe pasa por reducers, quien lee pasa por selectores, y la forma interna no se toca desde ningún otro sitio.
Esa pureza tiene consecuencias prácticas que van más allá de la elegancia. Un selector no despacha, no lee de la red, no toca el reloj ni el localStorage: recibe el estado y devuelve un valor, y por eso mismo se compone —la salida de uno alimenta a otro—, se memoiza —mismo estado, mismo resultado— y se prueba sin montar nada. La escritura, en cambio, es donde vive el efecto y el orden: los reducers son puros pero se orquestan con middleware, thunks y listeners. Separar las dos mitades no es purismo académico; es reconocer que leer y escribir tienen naturalezas distintas y merecen herramientas distintas.
Encapsulación
La forma del store se vuelve privada. Los componentes conocen nombres de selectores, no rutas de acceso a campos anidados.
Refactor seguro
Cambiar la forma —normalizar, renombrar, mover una rama— toca una función, no cincuenta componentes repartidos por la app.
Testabilidad
Al ser puros, se prueban con un objeto de estado de entrada y una aserción sobre la salida. Sin render, sin store.
Composición
La salida de un selector alimenta a otro. De piezas pequeñas y puras se construye toda la lógica de lectura.
Desacoplar el componente de la forma del estado
El acoplamiento entra por la puerta más inocente: un componente que lee estado.usuarios.entidades[id].perfil.nombre no depende de un dato, depende de tres niveles de estructura. El día que normalices el store, muevas el perfil o renombres una clave, ese componente —y todos sus clones— se rompe. El selector absorbe ese conocimiento y lo concentra en un único lugar.
// Acoplado: el componente conoce tres niveles de la forma interna.
const nombre = useSelector((s: RootState) => s.usuarios.entidades[id].perfil.nombre)
// Desacoplado: el componente pide un dato; el selector conoce la forma.
const nombre = useSelector((s: RootState) => selectNombreUsuario(s, id))
// El selector vive junto al slice y es lo unico que cambia si la forma cambia.
export const selectNombreUsuario = (s: RootState, id: string) =>
s.usuarios.entidades[id]?.perfil.nombre ?? "desconocido"
Conviene sentir el coste de no hacerlo, porque es asimétrico y engañoso. Escribir la lectura acoplada es más rápido hoy: un selector parece ceremonia innecesaria cuando solo hay un consumidor. Pero ese ahorro se cobra con intereses el día del refactor, cuando descubres que la ruta se repite en cuarenta componentes y que cambiar la forma del store significa una búsqueda-y-reemplazo frágil por todo el código, sin ayuda del compilador si los accesos son dinámicos. El selector convierte ese refactor de una operación distribuida y arriesgada en un cambio local y verificable. Como casi todo en ingeniería, la disciplina cuesta por adelantado y ahorra a plazo.
flowchart LR C[componente] -->|pide dato derivado| S[selector] S -->|conoce la forma| ST[store] R[refactor de la forma] -.solo toca.-> S style S fill:#89b4fa,color:#11111b style ST fill:#cba6f7,color:#11111b style C fill:#a6e3a1,color:#11111b
El diagrama captura la asimetría que buscamos: cuando la forma del store cambia, la onda de cambio muere en el selector y nunca alcanza al componente. Esa es la definición operativa de desacoplar.
Anatomía: selectores base y derivados
Conviene distinguir dos capas. Un selector base lee una rama del estado y devuelve lo que ya existe allí, sin transformarlo: preserva la referencia. Un selector derivado computa algo nuevo —una suma, un filtro, una proyección— y ahí acecha un problema sutil que domina el resto del nivel.
// Base: lee una rama del estado, devuelve una referencia estable.
const selectItems = (s: RootState) => s.carrito.items
// Derivado escalar: computa un numero. Barato y sin sorpresas.
const selectTotal = (s: RootState) =>
s.carrito.items.reduce((acc, i) => acc + i.precio * i.cantidad, 0)
// Peligro: devuelve un ARRAY NUEVO en cada llamada -> nueva referencia,
// aunque el contenido sea identico. useSelector compara por === y creera
// que cambio, disparando renders inutiles una y otra vez.
const selectCaros = (s: RootState) =>
s.carrito.items.filter((i) => i.precio > 100)
La distinción base/derivado no es terminológica: predice el comportamiento. Un selector base es seguro porque devuelve lo que ya existía, con su referencia intacta; un selector derivado escalar es seguro porque un número o un string se comparan por valor. El peligro vive en el tercer caso —el derivado que construye una estructura nueva—, y es tan común que domina el resto del nivel: filtrar, mapear, agrupar u ordenar produce siempre un array u objeto nuevo, y por tanto una referencia nueva, aunque el contenido sea idéntico al de la vez anterior.
useSelector de React-Redux decide si re-renderiza comparando por referencia estricta (===) la salida del selector entre despachos; desde la versión 8 está construido sobre useSyncExternalStore. Un selector derivado que crea un array u objeto nuevo cada vez devuelve una referencia distinta aunque los datos sean iguales, y provoca renders que no cambian nada en pantalla. Zustand vive el mismo dilema con su API useStore(selector), que compara con Object.is y ofrece useShallow como paliativo. El diagnóstico es idéntico en todo el ecosistema: derivar es barato, pero devolver una referencia nueva cada vez es caro.
Dónde viven y cómo se nombran
La convención sólida en 2026 es colocar los selectores junto al slice que conocen —no en el componente— y exportarlos con el prefijo select. La diferencia entre un selector global y uno de rama es puramente de composición: un selector que parte de RootState se combina con cualquier otro sin adaptación, mientras que uno que solo conoce su rama local necesita re-basarse antes de entrar en un createSelector que opera sobre el estado raíz. Cuando el estado es una colección no escribirás esto a mano: createEntityAdapter genera y re-basa los selectores por ti con getSelectors, como veremos en la cuarta lección.
// De rama: solo sabe de su porcion. Comodo, pero no compone directo.
const seleccionarItems = (tareas: EstadoTareas) => tareas.items
// Global: parte de RootState re-basando el de rama. Compone con cualquiera.
export const selectItems = (s: RootState) => seleccionarItems(s.tareas)
Regla de higiene: un componente debe seleccionar el dato más pequeño que necesita —un campo, no el objeto entero—, porque useSelector lo despierta cada vez que su salida cambia, y seleccionar de más lo hace reaccionar a cambios en campos vecinos que no le importan. La misma disciplina rige fuera de Redux: Zustand anima a useStore(s => s.x) en vez de tomar el store completo, y las librerías de signals la vuelven automática al rastrear qué leíste. El selector estrecho es, en todo el ecosistema de 2026, la unidad de suscripción.
Si te descubres metiendo lógica con consecuencias —marcar algo como leído, disparar una petición, escribir en disco— dentro de un selector, esa lógica no pertenece a la mitad de lectura, sino a un reducer, un thunk o un listener. El selector responde qué es verdad ahora dado el estado; nunca cambia esa verdad. Mantener esa línea es lo que conserva la pureza de la que dependen la composición y la memoización.
La lección profunda no es que los selectores ahorran tecleo, sino que redefinen qué es público y qué es privado en tu estado. Un store sin selectores tiene la forma expuesta: cada componente que lee un campo anidado firma un contrato implícito con esa estructura, y la suma de esos contratos convierte cualquier refactor de la forma en una operación a corazón abierto sobre toda la app. Un store con disciplina de selectores tiene la forma encapsulada: la estructura interna es un secreto que solo conocen los reducers que escriben y los selectores que leen, y del otro lado de esa frontera los componentes hablan un vocabulario estable de datos derivados que no menciona jamás cómo están guardados. Por eso reducers y selectores son simétricos y deben pensarse juntos: son las dos únicas puertas de una caja cuya forma interior nadie más tiene derecho a mirar. Adoptar esta disciplina desde el primer día es lo que permite, más tarde, normalizar el estado o cambiar de librería sin que la interfaz se entere; posponerla es acumular una deuda que se cobra entera, y de golpe, el día del primer refactor serio.
- Toma un componente que lea un campo anidado del store (
a.b.c.d) y extrae esa lectura a un selectorselectAlgocolocado junto a su slice. - Cambia deliberadamente la forma del store —anida un nivel más o renombra una clave— y comprueba que solo el selector necesita cambiar.
- Escribe un selector derivado que devuelva un array filtrado y observa, con las devtools, los renders que provoca aunque los datos no cambien.
- Prueba el selector en aislamiento: pásale un objeto de estado literal y asevera la salida, sin montar ningún componente.
- Reescribe un selector de rama para que parta de
RootStatey verifica que ahora compone con otro sin adaptarlo. - Clasifica cinco lecturas de tu app en base o derivada, y marca cuáles devuelven una referencia nueva en cada llamada: son las candidatas de la próxima lección.