wandres.dev
RENDIMIENTO · evitar re-renders

El problema del store global: una acción despierta a todos

El store de Redux notifica a cada suscriptor en cada despacho, sin filtrar por interés: el store conoce el estado entero, los suscriptores conocen su interés, y nadie cruza ambas informaciones. Esta lección diseca esa asimetría estructural, calcula el coste real que impone —lineal en el número de suscriptores, no en el tamaño del cambio— y explica las tres amortiguaciones con las que react-redux la vuelve tolerable: una única suscripción real al store, la delegación en useSyncExternalStore y el corte por igualdad referencial que transforma notificar a todos en preguntar a todos. Al final queda claro por qué el rendimiento de un store global es un problema de selectores y no de acciones, y qué residuo de coste no desaparece jamás.

⏱ 16 min

Un store global es, por definición, un objeto que muchos observan y que cualquiera puede cambiar. Esa doble propiedad —una fuente de verdad compartida y un canal de escritura abierto— es exactamente lo que hace valioso el patrón, y también lo que crea su tensión de rendimiento más profunda. El núcleo de Redux cabe en cien líneas y su función subscribe no admite matices: guarda una lista de oyentes y, al terminar cada reducción, recorre la lista entera. No hay filtro, no hay clave, no hay ámbito. Cambiar el nombre del usuario despierta al componente que solo mira un contador, y cambiar ese contador despierta al que solo mira el nombre. Este nivel entero nace de esa asimetría, y esta lección la mira de frente antes de estudiar cualquier remedio, porque casi todos los errores de rendimiento con estado global vienen de no haber entendido qué se notifica, a quién y con qué coste.

🎯 Al terminar esta lección sabrás
  • Ver por qué un store global no puede, estructuralmente, notificar solo a los interesados.
  • Calcular el coste real de un despacho: lineal en suscriptores, indiferente al tamaño del cambio.
  • Entender las tres amortiguaciones de react-redux y qué convierte cada una en tolerable.
  • Distinguir el coste que la librería elimina del residuo que ninguna librería puede borrar.

La asimetría estructural del broadcast

El contrato de un store observable es minúsculo y por eso mismo es rígido. subscribe recibe una función sin argumentos y la guarda; dispatch ejecuta el reducer y después recorre el registro llamando a cada función guardada. Fíjate en lo que ese contrato no dice: el oyente nunca declara qué parte del estado le interesa, y la notificación nunca informa de qué parte cambió. Ambas piezas de información existen —el store sabe qué rama se reemplazó, el componente sabe qué campo lee— pero el canal que las une es demasiado pobre para transportarlas.

// La esencia del store de Redux, sin adornos.
function crearStore(reducer, estadoInicial) {
  let estado = estadoInicial
  const oyentes = new Set()

  return {
    getState: () => estado,
    subscribe(fn) {
      oyentes.add(fn)
      return () => oyentes.delete(fn)
    },
    dispatch(accion) {
      estado = reducer(estado, accion) // una referencia nueva de raiz
      oyentes.forEach((fn) => fn())    // broadcast: sin filtro ni argumentos
      return accion
    },
  }
}

De ahí se deduce el modelo de coste que hay que memorizar: el precio de un despacho no depende de cuánto cambió el estado, sino de cuántos observadores hay. Reemplazar un booleano en una rama diminuta cuesta lo mismo que reescribir medio store, porque el trabajo dominante no es reducir, sino notificar. Es un coste lineal en el número de suscriptores y constante respecto al tamaño del cambio, justo lo contrario de la intuición que traemos de otros sistemas.

Esa asimetría no es un defecto de implementación que una versión futura vaya a corregir: es el precio de la inmutabilidad de raíz. Como cada reducción produce un objeto raíz nuevo, la única comparación barata en el nivel del store —comparar la raíz con ===— siempre da distinto, y por tanto el store nunca puede descartar a nadie. Saber si a un suscriptor concreto le importa exige ejecutar su propia lógica de lectura, y esa lógica vive en el suscriptor, no en el store.

flowchart TD
D[dispatch de una accion] --> R[reducer produce raiz nueva]
R --> N[notificar a TODOS los suscriptores]
N --> S1[selector del componente A]
N --> S2[selector del componente B]
N --> S3[selector del componente C]
S1 --> E1[resultado identico: sin render]
S2 --> E2[resultado distinto: render]
S3 --> E3[resultado identico: sin render]
style E1 fill:#a6e3a1,color:#11111b
style E3 fill:#a6e3a1,color:#11111b
style E2 fill:#fab387,color:#11111b

Las tres amortiguaciones de react-redux

Si cada componente conectado abriera su propia suscripción ingenua y re-renderizara al ser notificado, un store global sería inutilizable a partir de unas decenas de componentes. Lo que hace que el patrón funcione en aplicaciones con miles de nodos es una capa de amortiguación con tres mecanismos distintos, y conviene no confundirlos porque resuelven problemas diferentes.

🔌

Una suscripción, no mil

El Provider mantiene la suscripción real al store y propaga la notificación hacia abajo. El store recorre una lista corta, no una por componente, y el orden de propagación va de padres a hijos.

⚛️

useSyncExternalStore

Desde la versión 8, el bucle de comprobación se delega en React. Es React quien decide cuándo leer la instantánea, y quien garantiza que ningún componente vea dos versiones distintas del estado en el mismo commit.

✂️

El corte por igualdad

Ser notificado no es re-renderizar. useSelector ejecuta el selector, compara el resultado con el anterior usando === y, si son idénticos, no marca nada como sucio. El render se corta ahí.

📦

El agrupado de actualizaciones

Con React 18 el agrupado es automático: varios despachos dentro del mismo manejador producen un único ciclo de render, no uno por acción. Antes hacía falta pedirlo a mano con batch.

El tercer mecanismo es el conceptualmente decisivo, y merece una formulación explícita: la librería no consigue notificar solo a los interesados, sino algo distinto y más barato de implementar. Convierte la notificación en una pregunta. En lugar de decidir a quién avisar —imposible sin conocer los intereses—, avisa a todos y deja que cada uno responda si le importa, ejecutando su selector y comparando la respuesta con la de antes. El broadcast sigue ahí; lo que se filtra es su consecuencia.

Reducido a su esqueleto, el hook que hace ese trabajo cabe en unas pocas líneas conceptuales, y verlo desnudo desactiva buena parte de la magia que se le atribuye.

// Version conceptual de useSelector: notificar, ejecutar, comparar, decidir.
function useSelector(selector) {
  const store = useContext(ContextoRedux)
  const anterior = useRef(undefined)

  return useSyncExternalStore(
    store.subscribe,                    // React decide cuando comprobar
    () => {
      const nuevo = selector(store.getState())
      if (Object.is(nuevo, anterior.current)) return anterior.current
      anterior.current = nuevo          // solo aqui se dispara un render
      return nuevo
    },
  )
}

Ese cambio de plano tiene un efecto contable enorme. Un despacho ya no cuesta N renders sino N ejecuciones de selector más los pocos renders cuyo resultado cambió de identidad. Como un selector idiomático es un acceso a propiedad —el trabajo más barato que hace una máquina virtual de JavaScript— y un render es reconciliación más efectos más posible pintado, la diferencia entre ambos costes es de varios órdenes de magnitud. La amortiguación funciona porque traslada el trabajo del lado caro al lado barato de la balanza.

ℹ️
Por qué el orden padre-hijo no es un detalle

La arquitectura de suscripción anidada existía por una razón de corrección, no de velocidad: el problema de los hijos zombis. Si un hijo se entera de un cambio antes que su padre, puede ejecutar su selector sobre un estado en el que la entidad que leía ya fue borrada, y reventar leyendo una propiedad de undefined justo antes de que el padre lo desmonte. Propagar siempre de arriba abajo garantiza que el padre decida primero si el hijo sigue vivo. Hoy useSyncExternalStore aporta además la garantía contra el desgarro: dentro de un mismo commit, todos los componentes leen la misma versión del estado, aunque llegue un despacho a mitad del render.

El residuo que no desaparece

Amortiguar no es eliminar, y la parte honesta de esta lección es nombrar lo que sigue costando. Quedan tres residuos. El primero es el propio recorrido: cada despacho ejecuta todos los selectores registrados, y con miles de componentes conectados en pantalla eso es un trabajo real que crece con el tamaño de la interfaz visible, no con el del cambio.

El segundo residuo es el precio de cada pregunta. Un selector barato multiplicado por dos mil suscriptores sigue siendo barato; un selector que ordena o agrupa una lista, multiplicado por dos mil, es una congelación visible. La disciplina que se deriva es contraintuitiva para quien viene de pensar en renders: en un store global, el coste de leer se paga siempre, aunque no cambie nada, mientras que el coste de renderizar solo se paga cuando algo cambia. Por eso el selector caro es más peligroso que el render caro.

⚠️
La frecuencia de despacho es el multiplicador oculto

Todo lo anterior se escala por un factor que casi nadie mide: cuántas acciones por segundo despacha la aplicación. Un formulario que despacha en cada pulsación de tecla, un scroll que actualiza una posición o un socket que empuja mensajes convierten un coste tolerable por despacho en una carga permanente. Antes de optimizar selectores conviene preguntarse si ese estado necesitaba estar en el store global: la escritura de alta frecuencia es la carga que peor tolera un canal de difusión total, y a menudo la solución correcta no es hacer el broadcast más barato sino sacar ese dato de él.

El tercer residuo es el que ocupará las tres lecciones siguientes: una respuesta mal formulada. Si un selector devuelve una referencia nueva cada vez que se ejecuta, el corte por igualdad no puede cortar nada y el componente re-renderiza en todos y cada uno de los despachos de la aplicación entera. Ese componente ha vuelto, en la práctica, al mundo sin amortiguación, y lo ha hecho de forma invisible: nada falla, solo va lento.

Conviene además desconfiar de una métrica que se usa mucho y explica poco: el tamaño del store. Un store con megabytes de datos y pocos componentes conectados va perfectamente; un store diminuto leído por dos mil nodos con selectores torpes se arrastra. Lo que se paga en cada despacho no es guardar, sino leer, y el número que predice el coste es la cantidad de suscripciones activas multiplicada por el precio medio de cada una.

El mismo broadcast fuera de Redux

Sería un error archivar todo esto como una peculiaridad de Redux, porque la topología se repite en casi todas las alternativas ligeras. El store de Zustand es el mismo diseño con menos ceremonia: mantiene un conjunto de oyentes, setState los recorre enteros, y el hook ejecuta tu selector y compara el resultado con el anterior por identidad antes de decidir si renderiza. Cambian el tamaño del paquete y la ergonomía; no cambia la aritmética.

Lo interesante es que existen dos estrategias alternativas al broadcast, y ambas están representadas en el ecosistema. La primera es observar los accesos: Valtio envuelve el estado en un proxy que registra qué rutas leyó cada componente durante su render y suscribe a cada uno únicamente a esas rutas. La segunda es declarar las dependencias: Jotai construye un grafo de átomos donde la notificación viaja solo por las aristas que existen, de modo que un cambio en un átomo alcanza exclusivamente a sus derivados y a sus lectores.

📝
Tres respuestas al mismo problema, con tres facturas distintas

Preguntar a todos —Redux y Zustand— es barato de implementar y no exige nada al programador, pero paga un coste proporcional al número de suscriptores en cada despacho. Observar los accesos —Valtio y la familia de las señales— reduce el coste a los interesados reales, pero cobra el peaje del proxy en cada lectura y hace menos evidente por qué renderizó algo. Declarar el grafo —Jotai— consigue la propagación más precisa de las tres, a cambio de obligarte a expresar las dependencias por adelantado. Ninguna es superior en abstracto: eligen dónde poner el trabajo, y esa elección es la que después determina qué tipo de problemas de rendimiento tendrás.

Situar el broadcast en ese mapa evita una confusión frecuente. Migrar de Redux a Zustand no resuelve nada de lo que estudia este nivel, porque la clase de coste es idéntica y las técnicas también lo serán; migrar a un modelo de proxy o de grafo sí cambia la clase de problema, pero introduce la suya. Las cuatro lecciones que siguen se aplican, con cambios cosméticos de sintaxis, a cualquier sistema que responda a un cambio preguntando a sus lectores si les importa.

La librería no resolvió el broadcast: cambió la moneda en la que se paga

Es tentador leer la historia de react-redux como una serie de optimizaciones que fueron haciendo el store global cada vez más eficiente, hasta que el problema del broadcast se disolvió. Esa lectura es falsa y produce ingenieros que optimizan a ciegas. El broadcast sigue intacto, letra por letra, en el mismo bucle que recorre todos los oyentes en cada despacho, y ninguna versión futura lo eliminará, porque no es un defecto sino la consecuencia lógica de tener una raíz inmutable compartida: cuando toda escritura produce una referencia nueva en la cima, ninguna comparación barata en la cima puede distinguir a quién le importa. Lo que la librería hizo fue mucho más astuto que optimizar: cambió la moneda. Movió la decisión desde el store, que conoce el estado pero no los intereses, hasta el suscriptor, que conoce su interés pero no el estado, y pagó ese traslado con la única operación lo bastante barata como para ejecutarse miles de veces por despacho sin que se note, la comparación de identidad. Todo el rendimiento de un store global depende, en consecuencia, de una sola variable de diseño que no controla la librería sino tú: cuánto cuesta cada pregunta y con qué fidelidad su respuesta conserva la identidad cuando el contenido no ha cambiado. Un selector barato y estable convierte el broadcast en ruido de fondo irrelevante; un selector caro lo convierte en un impuesto proporcional al tamaño de la pantalla; un selector inestable lo convierte en una cascada de renders que ninguna memoización de componentes puede detener después. Por eso la afirmación central de este nivel es que el rendimiento de Redux no es un problema de acciones, ni de reducers, ni de tamaño del store, sino un problema de lectura: el store no te hace lento, te hace preguntas, y la lentitud es siempre el precio acumulado de tus respuestas.

⚔️ Instrumenta el broadcast antes de tocar nada
  1. Escribe un middleware de tres líneas que registre en consola el tipo de acción y la marca de tiempo, y mide cuántos despachos por segundo produce tu pantalla más viva.
  2. Suscríbete al store a mano con store.subscribe y cuenta las notificaciones durante una interacción típica; contrasta ese número con los renders que percibes.
  3. Añade a un selector cualquiera un contador de ejecuciones y comprueba empíricamente que corre en cada despacho, aunque su rama del estado no se toque.
  4. Localiza el selector más caro de tu aplicación y estima su coste total por despacho multiplicándolo por el número de componentes conectados que lo usan a la vez.
  5. Busca un dato de alta frecuencia —texto en escritura, posición de scroll— que viva en el store global y razona si pertenece ahí o a un estado local.
  6. Formula por escrito, para tu propia aplicación, cuál de los tres residuos domina: el recorrido, el precio de la pregunta o la respuesta inestable.