Inicio / Artículos / Por qué Redux o Zustand pertenecen a muchos desarrolladores, no a muchos lectores

Por qué Redux o Zustand pertenecen a muchos desarrolladores, no a muchos lectores

El número de lectores no es motivo suficiente para tener una tienda de clientes. Cuando varios escritores comparten invariantes, como un carrito de compras, es entonces cuando los reductores, la reproducción y las pruebas directas resultan útiles.

2370 palabras

Cuatro justificaciones comunes para utilizar Redux, Zustand o bibliotecas similares ya fueron desmontadas anteriormente: el “prop drilling”, las actualizaciones del lado del cliente, la difusión automática a todos los suscriptores y el uso de Context como opción predeterminada más segura. React ya aborda esos casos sin necesidad de un almacén adicional.

Ninguno de esos argumentos planteó la pregunta que realmente determina si se debe usar una biblioteca. No se trata de cuántos componentes *leen* un valor, sino de cuántos lugares no relacionados pueden *cambiarlo*, y de si esos cambios deben mantenerse consistentes entre sí posteriormente.

Un interruptor de tema tiene un único escritor: un control, una llamada a setTheme. Por eso no era necesario utilizar una biblioteca allí, sin importar cuántas pantallas consumieran el resultado. Un carrito de compras es un caso diferente: varios escritores actúan en direcciones distintas, y eso requiere un ejemplo distinto.

Un escritor frente a muchos

En la tarjeta del producto aparece un carrito con el texto “Añadir al carrito”; en un panel lateral se muestra como control de cantidad, como enlace para eliminar elementos, como campo de cupón que vuelve a calcular los totales y como opción para borrar todo. Cinco archivos, cinco puntos de mutación, cada uno capaz de alterar el mismo estado, sin que ninguno esté al tanto de los otros cuatro.

Comparemos con la bandera temática: un botón, un escritor. Cada lector simplemente muestra el último valor. No hay coordinación, porque solo una mano gira la rueda.

El carrito mantiene invariantes: tres en total. Un invariable es un hecho que debe seguir siendo cierto después de cualquier operación. En este caso del carrito: el total siempre es igual a la suma del precio de cada artículo multiplicado por su cantidad, menos el descuento activo; la cantidad nunca es negativa; dos artículos nunca comparten el mismo SKU — al añadir otra unidad de un producto existente, la cantidad aumenta en lugar de crearse una fila duplicada.

Cinco escritores, tres hechos que todo escritor debe preservar. Ese es el problema que Redux, Zustand o MobX existen para resolver, y no tiene nada que ver con cuántos componentes solo leen el carrito.

¿Qué sucede cuando cinco escritores independientes modifican esos mismos tres hechos sin que nada los regule?

Qué se rompe sin un lugar disciplinado

Imagínese tres de esos cinco puntos de mutación, cada uno escrito al estilo que elegiría su propietario, modificando directamente el objeto del carrito:

// components/AddToCartButton.jsx
function addItem(item) {
  cart.items.push(item);
  cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/QuantityStepper.jsx
function changeQuantity(sku, newQty) {
  const item = cart.items.find(i => i.sku === sku);
  item.quantity = newQty;
  cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
// components/CouponInput.jsx
function applyCoupon(code) {
  cart.discount = getDiscountFor(code);
}

getDiscountFor es una búsqueda simple: se ingresa el código y se obtiene el número del descuento. AddToCartButton.jsx y QuantityStepper.jsx vuelven a calcular cart.total. CouponInput.jsx, en cambio, no lo hace. La invariante “el total es la suma menos el descuento” se rompió, y el lenguaje no hizo nada; nadie era responsable de esa regla. Era una convención según la cual se esperaba que tres archivos la respetaran; uno de ellos lo olvidó.

Un reducer elimina esa confianza al obligar que toda mutación pase por una sola función. Un reducer toma el estado actual y una acción (un objeto sencillo que describe lo que ocurrió) y devuelve un estado completamente nuevo. Nunca modifica el objeto antiguo en su lugar.

// store/cartReducer.js
function computeTotal(items, discount) {
  const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  return subtotal * (1 - discount);
}

export function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const items = [...state.items, action.item];
      return { ...state, items, total: computeTotal(items, state.discount) };
    }
    case 'CHANGE_QUANTITY': {
      const items = state.items.map(i =>
        i.sku === action.sku ? { ...i, quantity: action.qty } : i
      );
      return { ...state, items, total: computeTotal(items, state.discount) };
    }
    case 'APPLY_COUPON': {
      const discount = getDiscountFor(action.code);
      return { ...state, discount, total: computeTotal(state.items, discount) };
    }
  }
}

computeTotal se ejecuta dentro de cada rama del switch, no porque cada autor lo recordara, sino porque usar una sola función y un solo switch hace que omitirla resulte incómodo. Los tres antiguos autores directos ahora describen los eventos en lugar de ejecutarlos:

// components/AddToCartButton.jsx
import { useCartDispatch } from '../store/CartContext';

function AddToCartButton({ item }) {
  const dispatch = useCartDispatch();
  return <button onClick={() => dispatch({ type: 'ADD_ITEM', item })}>Add to Cart</button>;
}
export default AddToCartButton;
// components/QuantityStepper.jsx
import { useCartDispatch } from '../store/CartContext';

function QuantityStepper({ sku, qty }) {
  const dispatch = useCartDispatch();
  return (
    <input
      type="number"
      value={qty}
      onChange={e => dispatch({ type: 'CHANGE_QUANTITY', sku, qty: Number(e.target.value) })}
    />
  );
}
export default QuantityStepper;

dispatch proviene de un pequeño archivo CartContext.jsx que combina useReducer (el hook integrado que devuelve el estado junto con la función de dispatch) con Context, de modo que cualquier componente pueda acceder a dicha función de dispatch. Ni los botones ni los controles de desplazamiento escriben ya cart.total = .... Ninguno necesita saber que existe computeTotal.

Las lecturas permanecen abiertas. Los componentes aún pueden leer cart.total para mostrar precios, resúmenes de pago o notificaciones sobre elementos no guardados. Solo las escrituras siguen un único camino: se envía una acción y el reducer genera el siguiente estado. La invariante reside en ese punto crítico y no en quienquiera que lo invoque.

Sé preciso: una sola función de escritura no hace que la regla sea correcta, sino que garantiza su aplicación consistente. Si computeTotal olvida el descuento por sí mismo, cada ruta de escritura produciría siempre el mismo total incorrecto. Eso sigue siendo mejor que la versión dispersa: un error uniforme es fácil de aislar. Un error que solo aparece cuando un archivo olvida una línea es mucho más difícil de encontrar, ya que las rutas correctas e incorrectas se ven idénticas hasta que se da con el caso faltante.

Cuando la ruta del cupón olvida realizar el recálculo, la interfaz puede seguir viéndose bien hasta que se produzca un cambio posterior en la cantidad y sea necesario volver a calcularlo, o hasta que el proceso de pago muestre un total que ya no coincide con los ítems individuales. Esa sensación intermitente es precisamente la razón por la cual las convenciones fallan: la ruta defectuosa es lo suficientemente rara como para pasar desapercibida con clics casuales, pero lo suficientemente común como para causar problemas reales.

Centralizar la regla no elimina la necesidad de una lógica cuidadosa en computeTotal. Sin embargo, sí garantiza que cada ruta de escritura utilice la misma función, de modo que una fórmula incorrecta sea consistente y, por lo tanto, detectable. Las actualizaciones dispersas ocultan el mismo error bajo la apariencia de que “en general funciona”.

Una única ruta de escritura disciplinada ya tiene valor en sí misma. ¿Qué más aporta cuando algo falla posteriormente?

Cada acción se convierte en una instantánea reproducible

Dos propiedades de reductor combinadas generan algo más sólido que un simple archivo de registro.

En primer lugar, las acciones son serializables: JSON simple sin funciones, instancias de clases ni referencias ocultas, solo { type: 'APPLY_COUPON', code: 'SAVE10' }. En segundo lugar, cartReducer es puro: un estado idéntico junto con acciones idénticas siempre producen un estado siguiente idéntico, sin lecturas externas.

Juntos, reproducir una secuencia desde el mismo punto de partida siempre lleva al mismo resultado final. Ese determinismo es lo que realmente utilizan las herramientas de desarrollo de Redux. No se limita a mostrar que ocurrió algo; almacena la lista de acciones como datos y vuelve a calcular el estado exacto después de cualquier paso seleccionado al hacer clic en él.

Tomemos el error anterior: el total resulta completamente incorrecto después de aplicar un cupón y luego eliminar un artículo. Con las Herramientas de Desarrollador abiertas, la sesión muestra ADD_ITEM, ADD_ITEM, APPLY_COUPON, REMOVE_ITEM. Después de APPLY_COUPON el total parece correcto; después de REMOVE_ITEM, en cambio, no lo es. El error tiene una ubicación específica: la rama REMOVE_ITEM — sin necesidad de distribuir comandos console.log ni reproducir manualmente la sesión del usuario.

Esta ventaja no es igual en todas las bibliotecas. Redux DevTools es la versión original y madura. El middleware devtools de Zustand conecta set() a la misma extensión, de modo que las actualizaciones aparecen como acciones nombradas. MobX suele mutar los observables directamente a través de proxies en lugar de utilizar una secuencia de acciones serializables, por lo que sus herramientas se centran más en los gráficos de reacción —qué cálculos se volvieron a ejecutar y por qué— que en un registro completo de viajes en el tiempo.

Replay necesita una función determinista que siempre mapee las mismas entradas a las mismas salidas. ¿Qué más ofrece eso además de una extensión para el navegador?

Un Reducer es simplemente una función a la que se puede llamar

cartReducer(startState, action) es una llamada ordinaria: se ingresan los argumentos y se obtiene el estado siguiente completo. Para probarlo no se necesita navegador, clics ni componentes renderizados.

// store/cartReducer.test.js
import { cartReducer } from './cartReducer';

test('APPLY_COUPON recomputes total', () => {
  const startState = {
    items: [{ sku: 'A1', price: 20, quantity: 2 }],
    discount: 0,
    total: 40,
  };
  const nextState = cartReducer(startState, { type: 'APPLY_COUPON', code: 'SAVE10' });
  expect(nextState.discount).toBe(0.10);
  expect(nextState.total).toBe(36); // 40 minus 10 percent
});

La prueba finaliza en milisegundos sin necesidad de una biblioteca de renderizado. Detecta directamente el error anterior: si APPLY_COUPON omite computeTotal, nextState.total seguiría siendo 40 y la verificación fallaría en esa rama defectuosa en lugar de debido a una discrepancia vaga en la interfaz.

La misma lógica que un setter de useState dentro de un manejador de clic no puede probarse de esa manera, y la razón no es JSX:

// hooks/useCoupon.js
import { useState } from 'react';

function useCoupon() {
  const [discount, setDiscount] = useState(0);
  const applyCoupon = code => setDiscount(getDiscountFor(code));
  return { discount, applyCoupon };
}
export default useCoupon;

Llamar a useCoupon() desde una prueba simple provoca un error. Los ganchos como useState solo funcionan durante el renderizado de React (o dentro de otro gancho llamado durante el renderizado), y se rastrean en relación con esa instancia de componente. Esas son las reglas de los ganchos: llamadas incondicionales en el mismo orden en cada renderizado, ya que React compara el estado de los ganchos por su posición de llamada y no por su nombre. Si se omite un gancho en algunos renderizados, la gestión de datos se desincroniza.

applyCoupon tampoco logra devolver un estado útil de la misma manera que cartReducer. setDiscount devuelve undefined. Programa una nueva renderización; el nuevo valor solo aparece en la siguiente ejecución del cuerpo del componente. No existe un valor de retorno que pueda verificarse, ya que el mecanismo consiste en “pedirle a React que vuelva a renderizar”, y no en “calcular y devolver un valor”.

Probarlo implica renderizar algo y leer lo que aparece en la pantalla:

// CartSummary.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import CartSummary from './CartSummary';

test('applying coupon updates the displayed total', () => {
  render(<CartSummary />);
  fireEvent.click(screen.getByText('Apply SAVE10'));
  expect(screen.getByTestId('total')).toHaveTextContent('36');
});

Se prueba el mismo hecho: el total después de aplicar un cupón, pero la verificación se realiza en el texto renderizado, tras una renderización completa y un clic simulado.

Una alternativa intermedia es renderHook de React Testing Library: se puede utilizar un hook sin JSX ni clics, y luego inspeccionar result.current. Aún se necesita el renderizador de pruebas de React bajo act, el cual aplica las actualizaciones y efectos antes de realizar las afirmaciones. Sigue siendo necesario un marco estructurado porque el estado del hook sigue estando dentro de una instancia montada (incluso si es mínima). cartReducer no necesitaba nada de eso; nunca estuvo vinculado a un componente.

La pruebas se relacionan con el acoplamiento. ¿Cómo sería la misma idea al decidir dónde termina un almacén de estado y dónde comienza otro?

Dónde realmente se establece el límite

Las invariantes iniciales también responden a otra pregunta: dónde debe detenerse un almacén de estado y comenzar el siguiente.

Los elementos items, discount y total pertenecen entre sí porque están relacionados: cambiar uno puede invalidar los valores que dependen de los demás. theme no debe formar parte de ese reductor, ya que nada relacionado con theme determina cart.total, y nada relacionado con el carrito determina theme. Se trata de estados independientes que simplemente coexisten en una misma aplicación.

Un producto real suele contar con varios de estos conjuntos de reglas, cada uno sin conocer la existencia de los demás:

cartStore     → items, discount, total
authStore     → user, session, permissions
themeStore    → theme
notifyStore   → toasts, unread count

En Redux esto se manifiesta como “slices”: un reductor por cada parte del árbol, combinados bajo una raíz sin que se fusione su lógica. En Zustand se utilizan llamadas separadas a create() (useCartStore, useAuthStore, …). En MobX existen clases observables independientes con sus propias acciones e invariantes, sin un reductor compartido.

Un componente puede leer varios almacenes en una sola renderización. Un resumen del carrito podría leer cartStore.total y authStore.user.currency para formatear el dinero. Las lecturas se combinan libremente. Las escrituras no deben cruzarse: el reductor del carrito no debe acceder al estado de autenticación, ni viceversa.

Si cambiar un almacén implica también cambiar otro para mantener cierto hecho verdadero —por ejemplo, cambiar la moneda obliga a volver a calcular el total del carrito— el límite fue definido incorrectamente. O bien las partes pertenecen a un único almacén coordinado, o necesitan un puente explícito cuya función sea mantenerlas sincronizadas, y no una dependencia implícita que nadie haya documentado.

La elección de la biblioteca sigue siendo importante para las convenciones del equipo, el middleware y el tamaño del ecosistema, pero eso es secundario. El filtro principal es estructural: varios desarrolladores más invariantes compartidos. Sin esa estructura, el propio Context, los reducers y las props de React ya cubren la mayoría de los casos en los que se necesita acceso global a datos. Con esa estructura, un almacén dedicado —Redux Toolkit, Zustand, MobX o un useReducer compartido con cuidado— cumple su función al centralizar todas las reglas en un solo lugar.

Los equipos a menudo descubren este límite tarde, después de que un carrito de compras, un flujo de reservas o una matriz de permisos ya cuenten con cinco puntos de mutación. Adaptar un reducer sigue siendo más económico que depurar totales intermitentes. Cuanto antes se nombre el invariante en el código, menos tendrá que compensar la interfaz de usuario con soluciones temporales.

Si una función cuenta únicamente con un autor y no tiene reglas interdisciplinarias, manténgala local. Si tiene varios autores y existen reglas que deben mantenerse independientemente de cada autor, otorgue a esas escrituras una única vía de acceso.

La respuesta real

Los análisis anteriores demostraron que el mero número de lectores nunca justifica la necesidad de una biblioteca; React ya aborda esos casos. Este artículo trata de la otra parte: cuántos lugares independientes pueden escribir y si esas escrituras deben conservar los mismos datos constituyen la verdadera prueba.

Una bandera de tema falla en esa prueba en todos los casos: un único escritor, sin invariante entre campos, nada que coordinar. Un carrito la supera de inmediato en todos los casos: cinco escritores, tres hechos que cualquiera de ellos puede alterar, un reductor que convierte “cinco personas deben recordar la regla” en “la regla se aplica incondicionalmente”, además de dos efectos secundarios útiles: la depuración como una línea de tiempo reproducible en lugar de con conjeturas, y pruebas que llaman a una función en vez de renderizar una pantalla para extraer un número.

Esa es la línea divisoria. No el número de componentes, sino el número de escritores y lo que debe mantenerse verdadero en todos ellos.

Dicho de otra manera: las bibliotecas para el estado del cliente son herramientas para coordinar a los escritores, no para distribuir a los lectores. La expansión de lectores es tarea de React. La coordinación de escritores —y las invariantes que esos escritores deben respetar— es cuando Redux, Zustand, MobX o un almacén equivalente se convierten en la solución adecuada y no en una costumbre.