wandres.dev
ZUSTAND A FONDO · el store minimalista

Selectores: la suscripción es del que lee

En Zustand el selector no es un accesorio de conveniencia sino el mecanismo mismo de suscripción: cada componente declara qué porción del store le importa y solo se re-renderiza cuando esa porción cambia bajo Object.is. Esta lección explica cómo useSyncExternalStore ejecuta el selector, por qué un selector que construye un objeto nuevo rompe la comparación por identidad y provoca renders infinitos o inútiles, cómo useShallow lo repara y en qué se diferencia de createWithEqualityFn de zustand/traditional, que sustituye al tercer argumento eliminado en la versión 5. Cierra con la regla que evita la mayoría de los problemas: selectores atómicos, baratos y puros, con la derivación cara movida fuera de la ruta de render.

⏱ 18 min

En Redux el selector era una comodidad para leer del store sin acoplarse a su forma. En Zustand es algo mucho más literal: el selector es la suscripción. Cuando escribes useCarrito((s) => s.cupon) no estás pidiendo un dato, estás declarando un contrato con el store que dice avísame solo si esta expresión cambia de valor. Todo el rendimiento de la librería descansa en ese contrato, y casi todos sus bugs de rendimiento nacen de firmarlo mal: de devolver un objeto nuevo en cada llamada, de calcular dentro del selector algo que no debería calcularse ahí, o de suscribirse al store entero por pereza. Esta lección desmonta la mecánica exacta de la comparación para que el contrato lo escribas con los ojos abiertos.

🎯 Al terminar esta lección sabrás
  • Entender cómo useSyncExternalStore ejecuta el selector y con qué criterio decide re-renderizar.
  • Diagnosticar el fallo de identidad de los selectores que construyen objetos o arrays nuevos.
  • Aplicar useShallow y distinguirlo de createWithEqualityFn de zustand/traditional.
  • Diseñar selectores atómicos, puros y baratos, sacando la derivación costosa de la ruta de render.

El contrato: selector, snapshot y Object.is

El hook que devuelve create delega en useSyncExternalStore, la API que React 18 introdujo precisamente para consumir fuentes externas sin desgarrar la interfaz durante el renderizado concurrente. Su funcionamiento es simple de enunciar y decisivo de comprender: React se suscribe al store, y cada vez que el store notifica, vuelve a ejecutar tu selector sobre el nuevo estado y compara el resultado con el anterior usando Object.is. Si son iguales, no hay render y ni siquiera se marca el componente. Si difieren, se programa el render.

flowchart TD
A[una accion llama a set] --> B[el store notifica a todos los suscriptores]
B --> C[React ejecuta el selector de cada componente]
C --> D{Object.is del resultado nuevo y el viejo}
D -->|iguales| E[sin render]
D -->|distintos| F[render de ese componente]
style E fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b

De ese diagrama salen tres consecuencias que gobiernan todo lo demás. Primera: el selector corre en cada cambio del store, para cada componente suscrito, así que debe ser barato y puro. Segunda: el criterio es la identidad, no la igualdad estructural, y para valores primitivos ambas coinciden pero para objetos no. Tercera: no existe suscripción por clave sino por resultado, de modo que la granularidad no la fija la forma del store sino la expresión que escribes.

function Cupon() {
  const cupon = useCarrito((s) => s.cupon)           // primitivo: Object.is basta
  const aplicar = useCarrito((s) => s.aplicarCupon)  // funcion estable: nunca re-renderiza
  return <input value={cupon ?? ''} onChange={(e) => aplicar(e.target.value)} />
}

Esa tercera consecuencia es la más liberadora y la que más cuesta interiorizar viniendo de Redux, donde la normalización del estado era en parte una técnica para hacer posibles las lecturas eficientes. Aquí la eficiencia no depende de cómo hayas guardado los datos sino de qué pide cada lector, de modo que un store con una forma cómoda para escribir no penaliza a quien lee, siempre que el selector proyecte algo que ya existe. La forma del estado vuelve a poder decidirse por el dominio y no por el rendimiento.

Llamar al hook sin selector devuelve el estado completo y equivale a suscribirse a todo: como cada set produce un objeto de estado nuevo, la comparación por identidad falla siempre y el componente se re-renderiza ante cualquier cambio del store, aunque no use nada de lo que cambió. No es un error de la librería, es la consecuencia coherente del contrato.

El fallo de identidad y useShallow

El problema aparece en cuanto un componente necesita dos campos a la vez y la mano escribe lo natural: un selector que devuelve un objeto literal con ambos. Ese objeto es nuevo en cada ejecución, Object.is lo declara distinto del anterior sin mirar su contenido, y el componente se re-renderiza en absolutamente todos los cambios del store. En la versión 5 el síntoma llegó a ser más ruidoso que un render de más: React puede detectar que el snapshot no es estable y lanzar el aviso de que la fuente externa devuelve un valor distinto en cada llamada, con un bucle de renders como consecuencia.

// mal: objeto nuevo en cada llamada, la identidad nunca coincide
const { cupon, lineas } = useCarrito((s) => ({ cupon: s.cupon, lineas: s.lineas }))

Hay dos reparaciones legítimas. La primera y preferible es no construir nada: llamar al hook dos veces con dos selectores atómicos. Suena a desperdicio y no lo es, porque cada llamada es una comparación de un primitivo o de una referencia estable, y React agrupa los renders resultantes en uno solo. La segunda, cuando de verdad necesitas un objeto —porque los campos son muchos o vienen de un cálculo—, es useShallow, que memoiza el último resultado y lo compara clave a clave en lugar de por referencia, devolviendo el objeto anterior si el contenido no cambió.

import { useShallow } from 'zustand/react/shallow'

const { cupon, lineas } = useCarrito(
  useShallow((s) => ({ cupon: s.cupon, lineas: s.lineas })),
)

// tambien resuelve el clasico: derivar un array de claves
const skus = useCarrito(useShallow((s) => s.lineas.map((l) => l.sku)))
⚠️
useShallow compara un nivel, y solo uno

useShallow recorre las claves de primer nivel y compara cada valor con Object.is. Si una de esas claves contiene un objeto que se reconstruye en cada set —porque lo produce un map, un filter o una propagación— la comparación superficial vuelve a fallar exactamente igual que la de identidad, y habrás añadido un envoltorio sin ganar nada. La regla es que useShallow sirve para agrupar valores que ya son estables por separado, no para estabilizar valores que no lo son. Si lo que devuelves es el resultado de una transformación, el problema no está en la comparación sino en que estás derivando dentro de la ruta de render.

Igualdad a medida: createWithEqualityFn

Hasta la versión 4, el hook aceptaba un segundo argumento con una función de igualdad, y era habitual escribir useStore(selector, shallow). La versión 5 lo eliminó del núcleo por una razón de fondo: useSyncExternalStore no admite comparadores personalizados, así que soportarlo obligaba a mantener una implementación paralela con sus propias garantías de concurrencia. Esa implementación no desapareció, se mudó a zustand/traditional, y ahí sigue disponible para quien la necesite.

import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'

export const useCarrito = createWithEqualityFn<Carrito>()(
  (set) => ({ /* estado y acciones */ }),
  shallow,   // igualdad por defecto para todos los selectores de este store
)

Merece la pena entender qué se gana y qué se cede al elegir esa vía. Se gana no repetir el envoltorio en cada componente, lo que al migrar una base de código grande desde la versión 4 evita tocar cientos de llamadas. Se cede visibilidad: la política de comparación deja de estar escrita junto al selector y pasa a vivir en la definición del store, de modo que quien lea un componente ya no puede saber, solo mirándolo, cuándo se re-renderiza. Y se cede también la implementación estándar, porque zustand/traditional mantiene su propio puente en lugar de apoyarse íntegramente en useSyncExternalStore. Para código nuevo, useShallow explícito es la elección por defecto; createWithEqualityFn es una herramienta de migración con fecha de caducidad.

📝
El selector corre durante el render, así que debe ser puro

Con renderizado concurrente, React puede ejecutar el selector más veces de las que intuyes: al preparar un render que después descarta, al reintentar tras una interrupción, o dos veces seguidas en modo estricto para detectar impurezas. Un selector que incremente un contador, escriba en una referencia, dispare analítica o llame a console.log esperando una ejecución por cambio te dará resultados inexplicables. La regla es tajante: el selector es una proyección, una función de estado a valor sin ningún efecto observable. Todo lo que quiera reaccionar a un cambio va en un efecto o, mejor aún y como veremos en la última lección, en una suscripción fuera del ciclo de render.

🎯

Selector atómico

Devuelve un primitivo o una referencia que el store ya guarda. Es la opción por defecto: sin envoltorios, sin memoización y con la comparación más barata posible.

🧩

useShallow

Agrupa varios valores estables en un objeto o array. Se aplica por componente, memoiza el último resultado y compara un nivel de profundidad.

⚖️

createWithEqualityFn

Fija una igualdad por defecto para todo el store. Útil al migrar desde la versión 4, donde el comparador viajaba en la llamada al hook.

🧮

Derivar fuera del render

Cuando el cálculo es caro, guárdalo en el store al escribir o memoízalo aparte. El selector debe leer, no computar.

Derivar sin pagarlo en cada render

El error más caro no es el de identidad sino el conceptual: usar el selector como si fuera un createSelector de Reselect. El selector de Zustand no memoiza nada por sí mismo y se ejecuta una vez por componente y por notificación, de modo que ordenar mil elementos dentro de él significa ordenarlos mil veces por segundo si el store es activo, aunque el resultado sea siempre idéntico. Hay tres salidas, y elegir entre ellas es una decisión de diseño, no de estilo.

La primera es derivar en el momento de escribir: si el total del carrito es función de las líneas, calcúlalo en la acción que las modifica y guárdalo como un campo más. Duplicas información, pero el coste se paga una vez por escritura en lugar de una vez por lectura, y en una interfaz típica hay órdenes de magnitud más lecturas. La segunda es memoizar aparte, envolviendo el cálculo con useMemo sobre el valor atómico que ya seleccionaste, o con una fábrica de selectores memoizados si la derivación se comparte entre componentes. La tercera es no derivar en React en absoluto: para valores de alta frecuencia —posición del ratón, progreso de una animación— la respuesta correcta es suscribirse fuera del ciclo de render, y ese es el terreno de la quinta lección.

// derivar barato sobre un valor ya seleccionado
const lineas = useCarrito((s) => s.lineas)
const total = useMemo(() => lineas.reduce((a, l) => a + l.cantidad, 0), [lineas])

Cuando la misma proyección se repite en varios componentes, extraerla a una fábrica de selectores mantiene la lógica en un solo sitio y hace que el punto de suscripción sea evidente al leer el componente. La fábrica devuelve el selector; la memoización, si el cálculo la merece, se aplica sobre el resultado y no dentro de la proyección.

// selectores compartidos: la forma del store queda encapsulada en un modulo
export const selLineas = (s: Carrito) => s.lineas
export const selCupon = (s: Carrito) => s.cupon
export const selLineaPorSku = (sku: string) => (s: Carrito) =>
  s.lineas.find((l) => l.sku === sku)

Ese último caso esconde un matiz importante: find devuelve una referencia que el store ya guarda, así que la comparación por identidad funciona y no hace falta ningún envoltorio. La distinción decisiva no es si el selector contiene una llamada a un método de array, sino si el valor devuelto es una referencia preexistente o un objeto recién construido. Localizar un elemento que ya está en el store es barato porque devuelve su referencia; filtrar, mapear o reducir construye estructuras nuevas y rompe la identidad en cada llamada.

El selector traslada el control del rendimiento de quien escribe a quien lee

Lo que de verdad cambia con el selector no es el número de renders sino la localización de la responsabilidad, y ese desplazamiento es la idea más profunda de toda la librería. En el modelo del contexto de React, quien decide el coste de una actualización es quien provee: la forma del árbol, la posición del provider y la granularidad con que hayas partido el valor determinan a quién despierta un cambio, y el consumidor no tiene voz —recibe todo lo que le llega y solo puede defenderse memoizando su propio render, que es curar el síntoma después del diagnóstico equivocado. Con selectores, la propiedad de esa decisión se invierte: el store notifica indiscriminadamente, sin saber ni querer saber quién le escucha, y es cada lector quien declara con una expresión el predicado que define su interés. Esa inversión tiene una virtud que va mucho más allá de ahorrar renders, y es que hace el rendimiento local y auditable: la razón por la que un componente se re-renderiza está escrita en su propia primera línea, no repartida entre la topología del árbol y las decisiones de un ancestro que quizá escribió otra persona. Se puede leer un componente y saber exactamente cuándo despierta, que es una propiedad que Redux solo consiguió a base de Reselect y que el contexto nunca tuvo. El precio, y aquí es donde muchos tropiezan, es que un contrato expresado como código arbitrario permite escribir contratos absurdos: nada impide que el predicado construya un objeto nuevo cada vez y prometa por tanto avísame siempre, ni que ordene diez mil filas para decidir si algo cambió. La comparación por identidad no es una limitación que haya que sortear con envoltorios, es la única comparación que puede ser constante en el tiempo, y todo lo que hagas para eludirla lo pagas en cada notificación del store. Por eso la regla final no es memoriza cuándo usar useShallow, sino algo más severo y más útil: si tu selector no es una proyección trivial de algo que el store ya guarda, el problema no está en cómo lo comparas sino en dónde has puesto el cálculo.

⚔️ Audita la suscripción de cada componente de tu app
  1. Instrumenta un componente que suscriba el store entero sin selector y cuenta sus renders durante una sesión normal. Añade un selector atómico y vuelve a contar.
  2. Escribe a propósito un selector que devuelva un objeto literal con dos campos y observa en la consola si React avisa de un snapshot inestable. Repáralo primero con dos selectores separados y luego con useShallow.
  3. Compara ambas reparaciones midiendo renders: comprueba que dos llamadas atómicas no producen dos renders porque React los agrupa en uno.
  4. Rompe useShallow deliberadamente devolviendo dentro del objeto el resultado de un map. Explica por qué la comparación superficial no salva ese caso.
  5. Toma una derivación cara que hoy vive en un selector y muévela a la acción que escribe. Mide el coste antes y después contando ejecuciones del cálculo, no renders.
  6. Migra un store al estilo de la versión 4 usando createWithEqualityFn con shallow por defecto y razona qué ganas y qué pierdes frente a aplicar useShallow componente a componente.