From n00b to ZeroCool / Profesionalización

Estado global en frontend sin sufrir: Redux, Zustand, Signals y otros dolores

Guía honesta para elegir estado global: Redux vs Zustand vs Signals. Ejemplos reales, errores comunes y checklist para production.

Lo que vale la pena leer aquí

Abres DevTools y ves el crimen: renders por todos lados, un useEffect que nadie se atreve a borrar y tres fuentes distintas de “la verdad”: props, state local y una store global que alguien metió en un sprint con deadline imposible.

Intro con gancho

Son las 11:47 pm. Tu laptop ya suena como avión despegando, tú jurabas que era “un cambio rápido” y de repente pasa lo clásico: el carrito se vacía a veces, el header enseña un usuario que ya cerró sesión y las notificaciones se quedan pegadas como si fueran celdas de Excel del 2007.

Abres DevTools y ves el crimen: renders por todos lados, un useEffect que nadie se atreve a borrar y tres fuentes distintas de “la verdad”: props, state local y una store global que alguien metió en un sprint con deadline imposible.

No es un bug aislado. Es el estado global diciéndote “ya no me uses así”.

Qué te vas a llevar

  • Cómo decidir cuándo sí necesitas estado global (y cuándo solo estás moviendo el problema de lugar).
  • Qué dolor resuelve (y qué dolor nuevo te compra) Redux, Zustand y Signals.
  • Un modelo mental simple: server state vs client state vs UI state.
  • Ejemplos prácticos con escenarios reales (carrito, sesión, toasts) y los tradeoffs.
  • Errores comunes que revientan en production y cómo blindarte.

Contexto práctico (la neta del “global”)

Antes de casarte con una librería, ponle nombre al problema.

1) No todo lo compartido es “global”

Mucho de lo que la banda mete a una store global realmente es:

  • UI state: modal abierto, tab activo, filtros de una sola pantalla.
  • Derived state: totales, isLoggedIn, cartCount.
  • Form state: inputs, validaciones, pasos del wizard.

Si lo subes a global “para que sea más fácil”, terminas con una tienda tipo cajón de sastre. Luego cada pull request toca lo mismo, nadie entiende dependencias y te da miedo hacer rollback porque “seguro rompe algo”.

2) Server state ≠ Client state

  • Server state: datos que vienen de API (productos, pedidos, perfil). Se cachea, se invalida, reintenta, se deduplica.
  • Client state: cosas que solo existen en el navegador (sesión local, carrito offline, theme, permisos calculados).

Si tu “estado global” es puro server state, muchas veces lo que necesitas no es Redux/Zustand/Signals: necesitas TanStack Query (o algo equivalente). Esa decisión te ahorra semanas de bugs intermitentes.

3) Reality check (MX/LatAm)

En el jale real te toca:

  • Internet intermitente: metro, combi, hotspot del cel. El estado tiene que aguantar recargas y reconexiones.
  • Negocio apretando: “mañana lo ve el cliente”, “ya lo vendimos”, “nomás haz que funcione”.

Aquí no ganas por filosofía. Ganas por debuggability, mantenimiento y por cuántas horas te cuesta perseguir cada bug.

Cómo elegir sin convertirlo en religión

Paso 1: Marca qué debe ser global (y qué no)

Regla rápida:

Ponlo en global si:

  • Lo usan muchas pantallas/componentes que ni se conocen.
  • Se lee/escribe desde lugares dispares (header + checkout + sidebar).
  • Debe sobrevivir navegación, refresh o rehidratación.

Déjalo local si:

  • Solo vive en un flujo/pantalla.
  • Se puede derivar de props o del cache del server.
  • Es temporal (open/close, hover, step actual).

Ejemplos típicos:

  • Global: session, cart, featureFlags, toasts.
  • Local: isPasswordVisible, selectedTab, draftForm.

Paso 2: Decide el nivel de formalidad

Piensa en tu equipo, rotación, y qué tan caro es un bug en production:

  • Alta formalidad (producto grande, regulado, muchos devs tocando) → Redux Toolkit.
  • Pragmatismo (equipo pequeño/mediano, shipping rápido, menos ceremonia) → Zustand.
  • Reactividad fina (performance y updates granulares) → Signals (según framework).

Paso 3: Define tu contrato de estado

Antes del setup, escribe el contrato mínimo:

  • ¿Qué estado existe?
  • ¿Qué acciones lo cambian?
  • ¿Qué persistes?
  • ¿Qué se deriva?

Si no lo puedes describir en 10 líneas, tu store ya se está inflando.

Paso 4: Tu estrategia de debug (la diferencia real)

Esto es lo que te salva cuando todo truena a las 2 am:

  • ¿Necesitas time travel, logs consistentes, trazabilidad? Redux.
  • ¿Quieres simpleza, menos boilerplate y buen performance con selectors? Zustand.
  • ¿Quieres updates ultra-granulares sin re-render de medio árbol? Signals.

Guía principal con ejemplos

Caso realista: carrito + sesión + toasts. Lo suficiente para que duela si lo haces mal.

Opción A: Redux Toolkit (cuando tu app ya es “empresa”)

Redux moderno no es el Redux 2017 de sufrimiento. Con Redux Toolkit (RTK) el boilerplate baja y el workflow se vuelve más parejo.

Cuándo lo usaría:

  • Equipo grande o creciendo (onboarding constante, gente que entra y sale).
  • Necesitas reglas claras: slices, actions, middlewares.
  • Quieres debugging consistente cuando cae un bug raro en production.

Ejemplo: slice de carrito

// cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

type CartItem = { id: string; title: string; price: number; qty: number };

type CartState = {
  items: Record<string, CartItem>;
  coupon?: string;
};

const initialState: CartState = { items: {} };

const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<Omit<CartItem, "qty">>) {
      const item = action.payload;
      const existing = state.items[item.id];
      state.items[item.id] = existing
        ? { ...existing, qty: existing.qty + 1 }
        : { ...item, qty: 1 };
    },
    setQty(state, action: PayloadAction<{ id: string; qty: number }>) {
      const { id, qty } = action.payload;
      if (!state.items[id]) return;
      if (qty <= 0) delete state.items[id];
      else state.items[id].qty = qty;
    },
    clearCart() {
      return initialState;
    },
  },
});

export const { addItem, setQty, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

Selectores (para evitar renders inútiles)

// selectors.ts
import type { RootState } from "./store";

export const selectCartItems = (s: RootState) => Object.values(s.cart.items);
export const selectCartCount = (s: RootState) =>
  Object.values(s.cart.items).reduce((acc, it) => acc + it.qty, 0);
export const selectCartTotal = (s: RootState) =>
  Object.values(s.cart.items).reduce((acc, it) => acc + it.qty * it.price, 0);

Decisión con filo:

  • Redux casi nunca es “lento” por sí solo. El problema típico es leer mal el estado (selectores enormes, no memoizar cuando aplica) y re-renderizar media app por un cambio chiquito.

¿Y la data del server?

Si ya estás en Redux, usa RTK Query para server state. Te quita de encima el trío maldito: loading/error/success en cada feature.

Bug que te evita: “se ve info vieja porque alguien olvidó invalidar cache manual”.

Estado global en frontend sin sufrir: Redux, Zustand, Signals y otros dolores - visual explicativa 1
Visual de apoyo: Intro con gancho

Opción B: Zustand (cuando quieres shipping rápido y controlable)

Zustand es ese punto medio bonito: store global sin tanto ritual, ideal para estado de cliente.

Cuándo lo elegiría:

  • Apps medianas, dashboards, productos iterando rápido.
  • Equipos chicos donde la ceremonia se siente como talacha.
  • Quieres buen performance sin pelearte con mil abstractions.

Ejemplo: store de sesión + carrito con persist

// useAppStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

type Session = { userId: string; email: string } | null;

type CartItem = { id: string; title: string; price: number; qty: number };

type AppState = {
  session: Session;
  cart: Record<string, CartItem>;
  login: (s: Exclude<Session, null>) => void;
  logout: () => void;
  addToCart: (item: Omit<CartItem, "qty">) => void;
  setQty: (id: string, qty: number) => void;
  clearCart: () => void;
};

export const useAppStore = create<AppState>()(
  persist(
    (set, get) => ({
      session: null,
      cart: {},
      login: (s) => set({ session: s }),
      logout: () => {
        // decisión: ¿logout también limpia carrito?
        // depende del negocio. aquí sí, por seguridad.
        set({ session: null, cart: {} });
      },
      addToCart: (item) =>
        set((state) => {
          const existing = state.cart[item.id];
          return {
            cart: {
              ...state.cart,
              [item.id]: existing
                ? { ...existing, qty: existing.qty + 1 }
                : { ...item, qty: 1 },
            },
          };
        }),
      setQty: (id, qty) =>
        set((state) => {
          if (!state.cart[id]) return state;
          if (qty <= 0) {
            const { [id]: _, ...rest } = state.cart;
            return { cart: rest };
          }
          return { cart: { ...state.cart, [id]: { ...state.cart[id], qty } } };
        }),
      clearCart: () => set({ cart: {} }),
    }),
    {
      name: "byteit-app",
      partialize: (state) => ({ session: state.session, cart: state.cart }),
    }
  )
);

Lectura con selector (la clave)

// Header.tsx
import { useAppStore } from "./useAppStore";

export function Header() {
  const email = useAppStore((s) => s.session?.email);
  const cartCount = useAppStore((s) =>
    Object.values(s.cart).reduce((acc, it) => acc + it.qty, 0)
  );

  return (
    <header>
      <span>{email ? email : "Invitado"}</span>
      <span>Carrito: {cartCount}</span>
    </header>
  );
}

Advertencia real:

  • Zustand te deja avanzar en corto, pero si no pones reglas (nombres, módulos por dominio, límites), en seis meses la store parece “misc.js” con esteroides.

Escena típica de QA: “cerré sesión, me logueé con otro usuario y todavía traía el carrito anterior”. No es bug de Zustand. Es una decisión de negocio + seguridad que nadie amarró.

Opción C: Signals (reactividad fina, menos renders)

Signals es un modelo donde el estado es reactivo a nivel de valor: los componentes se actualizan cuando leen una señal que cambió.

Pero ojo: “Signals” no es una sola librería. Depende del stack:

  • SolidJS: signals nativos.
  • Vue: ref/reactive (similar en espíritu).
  • Preact: @preact/signals.
  • Angular: signals en el framework.
  • React: no es signals-first; hay integraciones, pero conviene medir el costo.

Cuándo lo elegiría:

  • Apps bien interactivas (editores, canvas, dashboards pesados).
  • Necesitas granularidad sin meter memo, useMemo y useCallback por toda la app.
  • Tu framework ya juega chido con signals (o de plano estás en Preact/Solid/Vue/Angular signals).

Ejemplo conceptual (Preact Signals)

import { signal, computed } from "@preact/signals";

type CartItem = { id: string; price: number; qty: number };

export const cart = signal<Record<string, CartItem>>({});

export const cartCount = computed(() =>
  Object.values(cart.value).reduce((acc, it) => acc + it.qty, 0)
);

export function addToCart(item: Omit<CartItem, "qty">) {
  const existing = cart.value[item.id];
  cart.value = {
    ...cart.value,
    [item.id]: existing
      ? { ...existing, qty: existing.qty + 1 }
      : { ...item, qty: 1 },
  };
}

Tradeoff que sí se siente:

  • Signals te da updates súper finos, pero si tu stack es React “clásico”, puede sentirse como injerto. Y en equipos mixtos (una persona enamorada de signals, otra de Redux), acabas con dos paradigmas conviviendo… y eso duele más que cualquier librería.

Screenshots sugeridos

  1. Redux DevTools mostrando un addItem y el diff del estado.
  2. React Profiler: antes/después de usar selectors (renders reducidos).
  3. Zustand persist en Application -> Local Storage (la llave del store).
  4. Un ejemplo de bug: estado duplicado (misma info en server cache y store global).

Errores comunes + cómo no caer

1) Globalizar lo que era local

Síntoma: la store crece sin control; cada PR toca el mismo archivo.

Arreglo:

  • UI state trivial en componentes o contextos locales.
  • Stores por dominio: session, cart, ui.
  • Si algo sí es cross-cutting, escribe el contrato y quién es dueño.

2) Duplicar server state en una store global

Síntoma: datos viejos, inconsistencias, “a veces funciona”.

Arreglo:

  • Server state con TanStack Query/RTK Query.
  • En la store global guarda lo que no viene del server: token, preferencias, selecciones.

3) Selectors chafas que recalculan todo

Síntoma: cada click re-renderiza medio árbol; la laptop se pone como comal.

Arreglo:

  • Redux: selectores claros y memoización (Reselect) cuando aplique.
  • Zustand: lee slices pequeños con selectors; evita useStore() sin selector.
  • Signals: no metas cómputos gigantes dentro de computed si no lo necesitas.

4) Persistir de más (y filtrar datos sensibles)

Síntoma: el usuario cambia de cuenta y “hereda” cosas; riesgo de seguridad.

Arreglo:

  • Persistir solo lo necesario (partialize).
  • No persistir secretos/tokens si tu threat model no lo aguanta (XSS, compu compartida, cyber).
  • Define qué pasa en logout: ¿limpia carrito? ¿limpia cache?

5) Side effects dentro de reducers/stores sin control

Síntoma: requests duplicados, race conditions, bugs fantasmas.

Arreglo:

  • Redux: side effects en thunks/middlewares.
  • Zustand: encapsula efectos y cancela cuando aplique.
  • Signals: cuidado con efectos automáticos; pon límites claros.
Estado global en frontend sin sufrir: Redux, Zustand, Signals y otros dolores - visual explicativa 2
Visual de apoyo: Qué te vas a llevar

Checklist final

  • Separé server state (cache/invalidate) de client state (sesión, UI, carrito local).
  • El estado global tiene dueño (dominio) y contrato (acciones claras).
  • No metí UI state trivial en global “por comodidad”.
  • Leo estado con selectors pequeños (y memoización cuando aplica).
  • Definí qué se persiste y qué se limpia en logout.
  • Tengo estrategia de debug (DevTools/logs/profiler) antes de que truene en production.
  • Puedo explicar en 1 minuto por qué elegimos Redux/Zustand/Signals sin decir “porque está de moda”.

FAQ

1) ¿Necesito estado global si ya tengo Context en React?

Context jala, pero si lo usas para estado que cambia seguido, dispara renders amplios. Para cosas estables (theme, i18n) va perfecto. Para stores grandes y mutables, Zustand/Redux suelen dar mejor control.

2) ¿Redux sigue valiendo la pena en 2026?

Sí, cuando necesitas estandarización, tooling y trazabilidad. Con Redux Toolkit y RTK Query el costo bajó muchísimo. Donde duele es si lo usas para todo (incluyendo UI local) o si no separas server state.

3) ¿Zustand se vuelve un desmadre en apps grandes?

Puede, si no pones reglas: módulos por dominio, nombres consistentes, selectors y límites de persistencia. Zustand no te obliga a ordenarte; ahí es donde te puedes tropezar.

4) ¿Signals reemplaza Redux/Zustand?

No necesariamente. Signals es un modelo reactivo; puede ser store, parte del framework o convivir con otras herramientas. Si tu stack no es signals-first, calcula el costo de meter otro paradigma.

5) ¿Qué estado global es “seguro” de persistir en el navegador?

Preferencias (theme), flags, carrito no sensible, drafts locales. Evita persistir secretos/tokens si no controlas el riesgo (XSS, máquinas compartidas). Define limpieza en logout y expiración.

Siguiente episodio: teaser

La próxima guerra: formularios. Validaciones, performance, react-hook-form vs Formik vs “lo hago a mano”.

Porque nada grita “production” como un form que se rompe justo cuando el cliente ya metió su tarjeta.