El problema que resuelves con un store global#
A lo largo del Nivel 6 has aprendido varias formas de gestionar estado:
useStatepara valores simples dentro de un componente.- “Levantar el estado” para compartirlo entre hermanos.
- Context para evitar el prop drilling cuando un dato necesita llegar hondo.
useReducerpara centralizar la lógica cuando hay muchas transiciones relacionadas.
Todas estas herramientas tienen algo en común: el estado sigue dentro del árbol de React. Alguien es el dueño, lo crea con useState o useReducer, y el resto lo recibe por props o lo lee por context.
Esto funciona en la mayoría de apps. Pero hay un punto en el que empieza a ser difícil de escalar: cuando muchos componentes lejanos del árbol necesitan leer y mutar el mismo estado, y quieres que ese estado tenga una única fuente de verdad sin depender de la jerarquía.
Un store global resuelve exactamente eso: el estado vive fuera del árbol, accesible desde cualquier componente sin Provider ni props.
Cuándo NO lo necesitas (esto es lo importante)#
Antes de hablar de stores, el aviso más honesto es este: la mayoría de las apps no los necesitan.
Si el estado solo interesa a un componente → useState local.
Si dos o tres componentes cercanos comparten el dato → levanta el estado y pasa props.
Si el dato necesita llegar hondo pero no se muta mucho → context.
Un store global gana cuando:
- Muchos componentes lejanos entre sí leen y mutan el mismo estado.
- Quieres una única fuente de verdad fuera del árbol (no atada a ningún componente).
- El context se queda corto porque re-renderiza demasiado (lo ves a continuación).
Abusar del estado global acopla todo y hace difícil saber quién cambia qué. Es la herramienta que se saca cuando las anteriores no bastan.
Context tiene un límite: todos re-renderizan#
Recuerda cómo funciona context: el Provider envuelve el árbol con un value, y cuando ese value cambia, todos los componentes que llaman a useContext se re-renderizan.
Eso es por diseño. El problema es que no puedes suscribirte a una porción del value. Si el contexto tiene { equipo, favoritos, filtros } y solo cambian los favoritos, todos los componentes que usen useContext re-renderizan aunque solo necesiten el equipo.
El demo de abajo muestra el Team Builder con context: funciona, pero por dentro, al añadir un héroe, los tres HeroCard se re-renderizan aunque su contenido no cambie. No se aprecia a simple vista, pero es justo lo que el demo siguiente, con Zustand y un selector, evita.
Puedes mitigarlo con useMemo en el value del Provider, pero eso es añadir complejidad manual para esquivar una limitación de diseño.
Zustand: un store sin Provider#
Zustand es una librería de estado global para React. La idea central es sencilla:
- Llamas a
create()pasando una función que describe el estado inicial y las acciones. create()te devuelve un hook (por convención,useMiStore).- Cualquier componente llama a ese hook para leer el estado o las acciones.
- No hay Provider. No hay boilerplate. El store vive fuera del árbol.
import { create } from "zustand";
// Describe la forma del store: qué datos y qué acciones tiene.
interface EquipoStore {
equipo: Heroe[];
añadir: (hero: Heroe) => void;
quitar: (id: number) => void;
}
// create() recibe una función que recibe set y devuelve el objeto del store.
const useEquipoStore = create<EquipoStore>((set) => ({
// Estado inicial.
equipo: [],
// Acción: set recibe el estado actual y devuelve los cambios (como setState).
añadir: (hero) => set((s) => ({ equipo: [...s.equipo, hero] })),
// Acción: filtramos al héroe por id.
quitar: (id) => set((s) => ({ equipo: s.equipo.filter((h) => h.id !== id) })),
}));Desde cualquier componente:
// Sin selector: el componente se suscribe a todo el store.
function MiComponente() {
// Desestructuramos lo que necesitamos del hook.
const { equipo, añadir } = useEquipoStore();
// ...
}Selectores: suscribirse a lo que importa#
La ventaja real de Zustand sobre context está en los selectores. En vez de leer todo el store, le dices exactamente qué porción necesitas:
// Selector: "dame solo si este héroe está en el equipo".
// Si otro héroe se añade, este valor no cambia → el componente no re-renderiza.
const enEquipo = useEquipoStore((s) => s.equipo.some((h) => h.id === hero.id));Zustand ejecuta el selector en cada cambio de store y solo re-renderiza si el valor devuelto cambia. Con context no puedes hacer esto sin useMemo manual.
Pruébalo#
El demo de abajo muestra el mismo Team Builder pero con Zustand. Compara el código con el de context: no hay Provider, el store es global, y cada HeroCard se suscribe solo a si su héroe está en el equipo.
Observa el patrón de las acciones: set((s) => ({ equipo: [...s.equipo, hero] })). Zustand solo necesita el fragmento del estado que cambia; el resto lo fusiona solo. Y las acciones viven junto al estado, en el store, no repartidas por los componentes.
Zustand vs Redux Toolkit#
Zustand no es la única opción. Redux Toolkit (RTK) es la otra grande, y es importante conocerla porque la encontrarás en muchos proyectos.
La diferencia no es que uno sea mejor que el otro: es que resuelven el mismo problema con filosofías distintas.
Redux Toolkit: más estructura, más trazabilidad#
RTK organiza el estado en slices. Un slice es un bloque que agrupa el nombre, el estado inicial y los reducers de una porción del estado global:
import { createSlice, configureStore } from "@reduxjs/toolkit";
// createSlice genera el reducer y los action creators automáticamente.
const equipoSlice = createSlice({
// El nombre identifica el slice en Redux DevTools.
name: "equipo",
// Estado inicial de esta porción del store.
initialState: { lista: [] as Heroe[] },
reducers: {
// Cada clave es una acción; RTK permite mutar el estado aquí
// porque usa Immer por dentro para producir el objeto nuevo.
añadir(state, action) {
state.lista.push(action.payload);
},
quitar(state, action) {
state.lista = state.lista.filter((h) => h.id !== action.payload);
},
},
});
// configureStore crea el store global combinando todos los slices.
const store = configureStore({
reducer: { equipo: equipoSlice.reducer },
});En los componentes se usa con useSelector y useDispatch de react-redux, y hay que envolver la app en un <Provider store={store}>.
El trade-off honesto#
| Zustand | Redux Toolkit | |
|---|---|---|
| Boilerplate | Mínimo (un create()) | Más estructura (slices, Provider, configureStore) |
| Provider | No hace falta | Necesario |
| DevTools | Plugin disponible | Integrado y muy potente |
| Filosofía | Libre: defines el store como quieras | Convenciones claras |
| Cuándo brilla | Apps medianas, proyectos ágiles, equipos pequeños | Apps grandes, muchos devs, necesidad de trazabilidad |
Ni Zustand ni RTK es “el correcto”. La elección depende del tamaño del proyecto y las necesidades del equipo. En 2026, Zustand se usa mucho en proyectos nuevos por su simpleza; RTK sigue siendo el estándar en aplicaciones de empresa grandes con historial.
El patrón Flux#
Tanto Zustand como Redux siguen el mismo patrón de arquitectura de datos: Flux.
La idea es sencilla: los datos fluyen en una sola dirección.
Componente → dispara acción → store la procesa → estado nuevo → componente re-renderizaEsto hace el estado predecible: dado un estado y una acción, siempre sabes cuál será el estado siguiente. No hay mutaciones ocultas, no hay efectos secundarios en la actualización. Es la misma filosofía que useReducer, pero aplicada a un store fuera del árbol.
Comprueba lo que sabes#
Pregunta 1 de 4
¿Cuándo tiene sentido sacar el estado a un store global?
Tu turno#
Ejercicio · en esta página
Mueve el equipo a un store de Zustand
El Team Builder hasta ahora gestionaba el equipo con useReducer. Aquí lo sacas a un store global de Zustand: cualquier componente puede leer y mutar el equipo sin props ni Provider.
Paso 1: Store básico con Zustand
- create() define el store con equipo (array), añadir, quitar y vaciar
- El hook del store se usa desde el componente para leer el estado y las acciones
- Los tres botones (añadir, quitar, vaciar) funcionan correctamente
Paso 2: Reglas en el store y selectores finos
- Las reglas (no repetir héroe, máximo 6) viven en las acciones del store, no en el componente
- HeroCard usa un selector fino para suscribirse solo a si su héroe está en el equipo
- Se muestran estadísticas derivadas (número de héroes y winrate medio) calculadas fuera del store
Paso 3: Selector con transformación y mobile
- El ranking del equipo (ordenado por winrate) se deriva dentro del selector, con un comentario honesto de cuándo importa
- El comentario explica por qué un selector fino evita re-renders innecesarios
- La UI aguanta a 375px sin romperse (flex-wrap, tarjetas no se desbordan)
Ver soluciones
import { create } from "zustand";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// El store agrupa estado y acciones en un solo objeto.
// create() devuelve el hook con el que cualquier componente accede al store.
interface EquipoStore {
equipo: Heroe[];
// Las acciones reciben set para actualizar el estado.
añadir: (hero: Heroe) => void;
quitar: (id: number) => void;
vaciar: () => void;
}
// create() crea el store; set es la función para actualizar el estado.
const useEquipoStore = create<EquipoStore>((set) => ({
// Estado inicial: equipo vacío.
equipo: [],
// Acción añadir: nuevo array con el héroe añadido.
añadir: (hero) =>
set((s) => ({ equipo: [...s.equipo, hero] })),
// Acción quitar: filtramos al héroe por id.
quitar: (id) =>
set((s) => ({ equipo: s.equipo.filter((h) => h.id !== id) })),
// Acción vaciar: equipo vacío.
vaciar: () => set({ equipo: [] }),
}));
export default function App() {
// Leemos TODO el store: si cualquier campo cambia, este componente re-renderiza.
const { equipo, añadir, quitar, vaciar } = useEquipoStore();
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="seccion">
<h2 className="seccion__titulo">Roster</h2>
<div className="lista">
{ROSTER.map((hero) => (
<article key={hero.id} className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
<button
className="boton"
onClick={() => añadir(hero)}
>
Añadir
</button>
</article>
))}
</div>
</div>
<div className="seccion">
<h2 className="seccion__titulo">Tu equipo ({equipo.length}/6)</h2>
{equipo.length === 0 ? (
<p className="equipo-vacio">Aún no has añadido héroes.</p>
) : (
<div className="lista">
{equipo.map((hero) => (
<article key={hero.id} className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
<button
className="boton boton--quitar"
onClick={() => quitar(hero.id)}
>
Quitar
</button>
</article>
))}
</div>
)}
{equipo.length > 0 && (
<button className="boton boton--quitar" onClick={vaciar}>
Vaciar equipo
</button>
)}
</div>
</section>
);
} Por qué este nivel
- create() agrupa estado y acciones: sin Provider, sin boilerplate. Eso ya es una mejora clara sobre context para estado que muchos componentes necesitan.
- El hook del store se usa en App directamente: no hay un 'dueño' del estado en el árbol. Cualquier componente nuevo puede conectarse al store sin que nadie tenga que pasarle nada.
import { create } from "zustand";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
interface EquipoStore {
equipo: Heroe[];
añadir: (hero: Heroe) => void;
quitar: (id: number) => void;
vaciar: () => void;
}
const useEquipoStore = create<EquipoStore>((set) => ({
equipo: [],
añadir: (hero) =>
set((s) => {
// Las REGLAS viven aquí, en el store, no en el componente.
// Regla 1: no repetir héroe.
const yaEsta = s.equipo.some((h) => h.id === hero.id);
// Regla 2: máximo 6 en el equipo.
if (yaEsta || s.equipo.length >= 6) {
// Sin cambios: devolvemos el mismo estado.
return s;
}
return { equipo: [...s.equipo, hero] };
}),
quitar: (id) =>
set((s) => ({ equipo: s.equipo.filter((h) => h.id !== id) })),
vaciar: () => set({ equipo: [] }),
}));
// HeroCard usa un SELECTOR: se suscribe solo a la porción del store que necesita.
// Si otra parte del store cambia y equipo sigue igual, este componente NO re-renderiza.
function HeroCard({ hero }: { hero: Heroe }) {
// Selector: solo leemos las acciones (referencias estables; no provocan re-render).
const añadir = useEquipoStore((s) => s.añadir);
const quitar = useEquipoStore((s) => s.quitar);
// Selector: solo el array del equipo para calcular si este héroe ya está.
const enEquipo = useEquipoStore((s) => s.equipo.some((h) => h.id === hero.id));
const equiPoLleno = useEquipoStore((s) => s.equipo.length >= 6);
// El winrate se calcula aquí: no hace falta guardarlo en el store.
const winrate = (hero.victorias / hero.partidas) * 100;
return (
<article className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% victorias</p>
<button
className={enEquipo ? "boton boton--quitar" : "boton"}
disabled={!enEquipo && equiPoLleno}
onClick={() => enEquipo ? quitar(hero.id) : añadir(hero)}
>
{enEquipo ? "Quitar" : "Añadir"}
</button>
</article>
);
}
export default function App() {
// Selectores finos: solo los campos que App necesita renderizar.
const equipo = useEquipoStore((s) => s.equipo);
const vaciar = useEquipoStore((s) => s.vaciar);
// Estadísticas derivadas: no van al store, se calculan del estado.
const numHeroes = equipo.length;
const winrateMedio =
numHeroes === 0
? 0
: equipo.reduce(
(suma, h) => suma + (h.victorias / h.partidas) * 100,
0,
) / numHeroes;
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="seccion">
<h2 className="seccion__titulo">Roster</h2>
<div className="lista">
{ROSTER.map((hero) => (
// HeroCard lee el store por su cuenta: App no le pasa estado.
<HeroCard key={hero.id} hero={hero} />
))}
</div>
</div>
<div className="seccion">
<h2 className="seccion__titulo">Tu equipo ({numHeroes}/6)</h2>
<p className="stats">Winrate medio: {winrateMedio.toFixed(0)}%</p>
{numHeroes === 0 ? (
<p className="equipo-vacio">Aún no has añadido héroes.</p>
) : (
<div className="lista">
{equipo.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
)}
{numHeroes > 0 && (
<button className="boton boton--quitar" onClick={vaciar}>
Vaciar equipo
</button>
)}
</div>
</section>
);
} Por qué es mejor que el anterior
- Las reglas (no duplicar, máximo 6) están en las acciones del store: un solo sitio, testeable por separado del componente.
- HeroCard con selector fino: solo re-renderiza cuando cambia 'si este héroe concreto está en el equipo'. Con context, ese componente re-renderizaría con cualquier cambio del value del Provider.
- Las estadísticas derivadas se calculan en el render, no van al store: el store debe contener estado mínimo, no datos que se pueden calcular a partir de él.
import { create } from "zustand";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
interface EquipoStore {
equipo: Heroe[];
añadir: (hero: Heroe) => void;
quitar: (id: number) => void;
vaciar: () => void;
}
const useEquipoStore = create<EquipoStore>((set) => ({
equipo: [],
añadir: (hero) =>
set((s) => {
// Las REGLAS viven en el store: no repetir héroe, máximo 6.
const yaEsta = s.equipo.some((h) => h.id === hero.id);
if (yaEsta || s.equipo.length >= 6) return s;
return { equipo: [...s.equipo, hero] };
}),
quitar: (id) =>
set((s) => ({ equipo: s.equipo.filter((h) => h.id !== id) })),
vaciar: () => set({ equipo: [] }),
}));
function HeroCard({ hero }: { hero: Heroe }) {
const añadir = useEquipoStore((s) => s.añadir);
const quitar = useEquipoStore((s) => s.quitar);
const enEquipo = useEquipoStore((s) => s.equipo.some((h) => h.id === hero.id));
const equiPoLleno = useEquipoStore((s) => s.equipo.length >= 6);
const winrate = (hero.victorias / hero.partidas) * 100;
return (
<article className="tarjeta">
<h3 className="tarjeta__nombre">{hero.nombre}</h3>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% victorias</p>
<button
className={enEquipo ? "boton boton--quitar" : "boton"}
disabled={!enEquipo && equiPoLleno}
onClick={() => enEquipo ? quitar(hero.id) : añadir(hero)}
>
{enEquipo ? "Quitar" : "Añadir"}
</button>
</article>
);
}
export default function App() {
// Selector que TRANSFORMA el estado antes de devolverlo al componente.
// Zustand ejecuta el selector en cada cambio de store y re-renderiza solo si
// el valor devuelto cambia. Aquí devolvemos un array nuevo cada vez que equipo
// cambia, lo que está bien: el contenido ha cambiado de verdad.
//
// Con el React Compiler (React 19) este selector se memoiza automáticamente;
// en proyectos sin él, puedes pasarle un comparador personalizado como segundo
// argumento (shallow de zustand/shallow) si el selector devuelve objetos nuevos
// con el mismo contenido y quieres evitar el re-render.
const ranking = useEquipoStore((s) =>
// Ordenamos una COPIA del array: sort muta, spread protege el original.
[...s.equipo].sort(
(a, b) =>
b.victorias / b.partidas - a.victorias / a.partidas,
),
);
const vaciar = useEquipoStore((s) => s.vaciar);
const numHeroes = ranking.length;
const winrateMedio =
numHeroes === 0
? 0
: ranking.reduce(
(suma, h) => suma + (h.victorias / h.partidas) * 100,
0,
) / numHeroes;
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="seccion">
<h2 className="seccion__titulo">Roster</h2>
<div className="lista">
{ROSTER.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
</div>
<div className="seccion">
<h2 className="seccion__titulo">Tu equipo — ranking ({numHeroes}/6)</h2>
<p className="stats">Winrate medio: {winrateMedio.toFixed(0)}%</p>
{numHeroes === 0 ? (
<p className="equipo-vacio">Aún no has añadido héroes.</p>
) : (
<div className="lista">
{ranking.map((hero) => (
// La lista ya está ordenada por winrate dentro del selector.
<HeroCard key={hero.id} hero={hero} />
))}
</div>
)}
{numHeroes > 0 && (
<button className="boton boton--quitar" onClick={vaciar}>
Vaciar equipo
</button>
)}
</div>
</section>
);
} Por qué es mejor que el anterior
- El selector que ordena el ranking hace la transformación una sola vez, donde tiene sentido: junto a la suscripción. Zustand ejecuta el selector en cada cambio y solo repinta si el resultado cambia.
- El comentario sobre shallow y el React Compiler es honesto: explica cuándo un selector así importa y cuándo el compilador lo resuelve automáticamente.
- A 375px, flex-wrap y max-width en las tarjetas evitan que el grid se rompa. Es mobile-first sin necesidad de una media query.