Medición de los gestores de estado de React: comparación de conteos de renders y costo del paquete.
Una aplicación de carritos desarrollada con ocho patrones de estado de React, analizada en cuanto a renders innecesarios y tamaño del paquete comprimido, y lo que indican las cifras sobre Zustand, Valtio y Context.
La pregunta sobre qué gestor de estado de React elegir surge constantemente, y el debate suele basarse en opiniones más que en mediciones. Un enfoque más útil es desarrollar la misma funcionalidad básica con cada patrón común, mantener el comportamiento constante mediante un conjunto de pruebas compartido, y contar qué es lo que realmente difiere: con qué frecuencia se vuelve a renderizar los componentes y cuántos kilobytes añade cada opción. Este artículo explica un experimento de este tipo con ocho patrones, detalla por qué surgen los números que se obtienen y ofrece una receta reproducible para realizar la misma comparación con su propio estado.
Cómo se configura el experimento
La aplicación de prueba es deliberadamente sencilla: un carrito de compras con un botón de manzana, un botón de plátano, un total en tiempo real y una insignia de tema cuyo valor nunca cambia. Cada implementación se somete a la misma prueba de comportamiento, que hace clic en manzana tres veces y en plátano dos veces, verificando que los resultados sean idénticos. Mientras se ejecuta la prueba, un contador registra cada renderizado de cada componente. Por separado, esbuild mide cuánto añade cada solución a un paquete minificado y comprimido, excluyendo a React ya que todas las opciones generan el mismo costo.
Los ocho competidores son:
- lifted
useStatetransmitido a través de props - Contexto combinado con
useReducer - Redux Toolkit
- Zustand
- Jotai
- Valtio
- MobX
- Un almacén escrito a mano basado en
useSyncExternalStore, sin dependencias
Todas las ocho cumplen con el contrato común, por lo que cualquier diferencia se refiere al costo y no a la corrección:
✓ lifted useState › passes the shared cart contract
✓ Context + useReducer › passes the shared cart contract
✓ Redux Toolkit › passes the shared cart contract
✓ Zustand › passes the shared cart contract
✓ Jotai › passes the shared cart contract
✓ Valtio › passes the shared cart contract
✓ MobX › passes the shared cart contract
✓ useSyncExternalStore › passes the shared cart contract
Tests 8 passed (8)
Aquí se enumeran las versiones de la biblioteca y el entorno de ejecución utilizados para las mediciones, junto con el repositorio que alberga las ocho implementaciones, la prueba compartida y ambos scripts de medición:
Tested with react@19.2.8, zustand@5.0.15, @reduxjs/toolkit@2.12.0, react-redux@9.3.0,
jotai@2.20.2, valtio@2.3.2, mobx@7.0.3, mobx-react-lite@5.0.3, node 22.
Repo: github.com/noorjsdivs/state-patterns — 8 implementations, 1 shared test, 2 measurements.
Dos opciones para mantener una comparación justa
Ambas provienen de errores que aparecen en aplicaciones reales, y no de las normas de pruebas de rendimiento.
Primero, cada implementación crea su almacenamiento dentro de un componente en lugar de a nivel de módulo. De esta manera, cada aplicación iniciada comienza realmente desde cero, y no hay fugas de estado entre las ejecuciones de prueba.
Segundo, la insignia del tema existe únicamente como un elemento pasivo. Se suscribe a un valor que nunca se modifica al hacer clic, por lo que cualquier renderizado que realice durante la prueba es un trabajo inútil cuya culpa solo puede atribuirse al patrón de estado.
Qué está deliberadamente fuera del alcance
El concurso abarca únicamente el estado compartido del cliente. TanStack Query no está incluido porque gestiona un caché de datos del servidor, lo cual es un problema distinto con reglas diferentes. El estado de la URL no está incluido porque la barra de direcciones pertenece al enrutador. Es probable que su aplicación necesite ambos, pero ninguno compite en este evento en particular.
Sorpresa número uno: el tamaño del código es casi idéntico
Antes de los números interesantes, hay uno aburrido que merece atención. Las ocho implementaciones varían entre 39 y 58 líneas de código. La opción más compleja, Redux Toolkit con 58 líneas, es solo diecinueve líneas más larga que el enfoque más simple posible, lifted state con 39 líneas. A esta escala, el argumento sobre el código genérico que domina tantas discusiones sobre gestión de estado se reduce a diecinueve líneas. Las diferencias reales están en otro lugar y solo se vuelven visibles con herramientas de medición.
El marcador de rendimiento
Aquí se muestra cuántas veces se renderizó cada componente durante los cinco clics, incluyendo el montaje inicial:
5 clicks (3 apple, 2 banana) Apple Banana Total Theme(idle)
lifted useState 6 6 6 6
Context + useReducer 6 6 6 6
Redux Toolkit 4 3 6 1
Zustand 4 3 6 1
MobX 4 3 6 1
useSyncExternalStore 4 3 6 1
Jotai 5 4 7 2
Valtio 2 2 2 1
De estas columnas surgen tres historias distintas.
Lifted state y Context vuelven a renderizar todo
Con el estado elevado mediante useState y un único Context, cada componente se renderiza con cada clic. La insignia del tema se mostró seis veces aunque su valor nunca cambió, y el botón de plátano se renderizó con cada clic en la manzana. Este es el mecanismo concreto detrás de la advertencia común de que Context no es un gestor de estado. useContext suscribe a un componente al valor completo del contexto, por lo que cada vez que ese valor cambia de identidad, todos los componentes consumidores se renderizan. Colocar todo tu estado en un único contexto equivale, en efecto, a tener un estado elevado con una capa adicional.
La solución habitual es dividir el estado en varios contextos o memorizar a los componentes consumidores, pero esto no se midió aquí. La fila refleja cómo se escribe Context con mayor frecuencia: un proveedor que alberga un único valor.
Cuatro APIs, un resultado idéntico
Redux Toolkit, Zustand, MobX y el almacén escrito a mano producen exactamente la misma columna: cada componente se renderiza una vez al montarse y nuevamente solo cuando realmente cambia el fragmento de estado que lee. Sus APIs no se parecen en nada, pero su comportamiento en tiempo de ejecución es idéntico, ya que los cuatro se basan en la misma idea fundamental. Los componentes escuchan un fragmento específico del estado en lugar de todo el almacén, y solo reciben notificaciones cuando ese fragmento cambia. Para conocer más detalles sobre cómo funciona esa selección y la comprobación de igualdad en una de estas bibliotecas, consulte cómo React Redux decide cuándo volver a renderizar.
Números sospechosamente bajos de Valtio
A primera vista, la columna de Valtio parece un contador roto: dos actualizaciones para un botón que se hizo clic tres veces. Las cifras son correctas. Valtio agrupa las notificaciones de cambios en la cola de microtareas, por lo que varias actualizaciones rápidas y síncronas se reducen a una sola actualización por componente, y los valores finales siguen siendo correctos.
Hay una advertencia importante. Una persona que hace clic a velocidad normal genera una actualización por cada clic, ya que cada clic termina antes de que comience el siguiente. La ventaja solo se aprecia cuando las actualizaciones llegan en ráfagas, como ocurre con los mensajes de WebSocket, los datos en streaming o los eventos de arrastre. Si su aplicación tiene ese perfil, esta columna merece una atención detallada.
La actualización adicional constante de Jotai
Jotai renderizó cada componente una vez más que el grupo basado en selectores, incluyendo dos renderizaciones para la insignia del tema inactivo. Su nivel de detalle es correcto, ya que los componentes siguen reaccionando únicamente a los elementos que utilizan, y el renderizado adicional es uniforme en todas las columnas. Ese patrón indica algún problema en la secuencia inicial de montaje con el proveedor Store y no una fuga de suscripciones, pero la causa exacta no se identificó. Considérelo como una pregunta abierta en lugar de un veredicto en contra de Jotai.
Análisis del tamaño del paquete
Esto es lo que cada patrón agrega a un paquete comprimido, contando tanto el código propio del patrón como su biblioteca, tratando a React como externo:
lifted useState 0.4 KB
useSyncExternalStore 0.5 KB (zero dependencies)
Context + useReducer 0.5 KB
Zustand 0.7 KB
Valtio 2.7 KB
Jotai 4.4 KB
Redux Toolkit 10.7 KB
MobX 13.2 KB
La opción más pesada es 33 veces mayor que la más ligera. En otras palabras, Redux Toolkit y MobX juntos suman 23,9 KB, mientras que los seis patrones restantes en conjunto totalizan 9,2 KB.
Lo interesante es cómo esta lista interactúa con el marcador de rendimiento. Solo dos patrones combinan un comportamiento de renderizado perfecto con un tamaño inferior a un kilobyte: Zustand y el almacén escrito a mano. Redux Toolkit logra la misma columna de renderizado con 10,7 KB, y MobX con 13,2 KB. Eso no es necesariamente un defecto en ellos; es simplemente un costo, y ese costo solo tiene sentido una vez que se sabe qué se obtiene a cambio.
Tres opciones prácticas y su costo respectivo
Para el estado del cliente compartido en una aplicación típica, las mediciones indican tres herramientas que vale la pena considerar.
Zustand como opción por defecto
Zustand ofrece una granularidad de renderizado perfecta con 0,7 KB y solo 54 líneas de código; además, su API casi no requiere explicaciones para un nuevo miembro del equipo: se trata de un hook que acepta un selector. En esta prueba, su rendimiento fue similar al de Redux, pero con aproximadamente una quinta parte del tamaño.
Ese resultado depende de una sola disciplina: siempre se debe seleccionar un fragmento específico. Una llamada como useCart(s => s) suscribe al componente a todo el almacén, lo que reproduce el problema del Context. La solución eficiente radica en escribir selectores más específicos, no en el nombre de la biblioteca. Si un selector devuelve un nuevo objeto o array en cada llamada, también se necesita una función auxiliar para la igualdad superficial, de lo contrario cada actualización parecerá un cambio.
Un almacén useSyncExternalStore escrito a mano
Esta opción es la que hace que el experimento sea más atractivo. La implementación completa, incluido el almacén, cabe en 58 líneas, añade 0.5 KB, coincide exactamente con la columna de renderizado de Redux y no presenta dependencias que requieran auditoría o actualización. useSyncExternalStore es la herramienta primitiva que proporciona React mismo para suscribirse a almacenes externos de forma segura, incluso durante el renderizado concurrente. Para los autores de bibliotecas o para equipos que prefieren dependencias mínimas, es suficiente. La contrapartida es que usted es responsable del código: los herramientas de desarrollo, el middleware y la persistencia son algo que debe crear usted mismo si los necesita.
Valtio para actualizaciones intermitentes
El agrupamiento de microtareas de Valtio era medible, real y único entre estos competidores, y 2,7 KB es un precio razonable por ello. Escribir una mutación directa como state.apples++ y ver que se actualizan exactamente los componentes adecuados resulta casi demasiado conveniente, pero los conteos de renderizado confirman que el comportamiento es genuino.
Por qué los demás pierden esta competencia sin ser malos
La estructura de la prueba es importante, ya que favorece un estado pequeño y plano. Las otras opciones tienen ventajas que esta aplicación no puede aprovechar:
- Los 10,7 KB de Redux Toolkit se justifican por las herramientas de desarrollo, la depuración con viajes en el tiempo, los middleware y las convenciones que funcionan bien en grandes organizaciones. Con cincuenta desarrolladores trabajando en una misma base de código, esas convenciones son el verdadero producto.
- El modelo observable de MobX es más fuerte en código de dominio con muchas clases, algo que esta aplicación no tiene.
Una regla de diseño que no es cuestión de gusto
Cada implementación aquí creó su almacén dentro de un componente. Un almacén definido a nivel de módulo sobrevive al desmontaje, y así es como un carrito de compras persiste tras el cierre de sesión y da la bienvenida a la siguiente persona que inicia sesión en el mismo dispositivo. También provoca que el estado se filtre entre pruebas y entre solicitudes durante la renderización en servidor. Si solo toma una regla de revisión de código de esta comparación, que sea esta: limite los almacenes al árbol de componentes, generalmente creándolos en un proveedor, a menos que quiera intencionalmente un ciclo de vida global.
Ejecutar el mismo concurso en su propia aplicación
Puede reproducir esta medición para su propio estado en una tarde:
- Elija el elemento de estado compartido más debatido en su aplicación y construya un conjunto de cuatro componentes alrededor de él: dos componentes que escriben, uno que lee un valor derivado y otro que actúa como observador pasivo.
track() en el cuerpo de cada componente requiere unas diez líneas de código. El recuento del “espectador” representa el costo del Context, medido con precisión en lugar de estimado.esbuild --bundle --minify, indicando que React es un archivo externo. El proceso tarda unos segundos y agrega una columna con el tamaño en kilobytes a los resultados.Preguntas abiertas
Dos hilos siguen sin estar fijados. El más pequeño es la causa de la renderización adicional en Jotai. El más grande es React Compiler. La fila lifted-state muestra 6/6/6/6 precisamente porque nada en ella está memorizado, y la memorización es exactamente lo que automatiza el compilador. Ver si esa fila se alinea con los almacenes basados en selectores en código de producción real, y no solo en una demostración, es el siguiente experimento obvio.
Conclusiones clave
- A pequeña escala, las diferencias en el código genérico entre las bibliotecas de estado son insignificantes; es en el comportamiento de renderización y el tamaño del paquete donde realmente difieren.
- Un único Contexto que almacena estado cambiante vuelve a renderizar a todos los consumidores, incluidos componentes que nunca leen el valor modificado.
- Los almacenes basados en selectores, ya sea Redux Toolkit, Zustand, MobX o un almacén personalizado con
useSyncExternalStore, convergen en el mismo patrón de renderización eficiente.