Por qué existe Redux y cuándo tiene sentido usarlo
En una aplicación pequeña de React, el estado vive cómodamente dentro de los componentes. Un formulario guarda sus campos con useState, un menú recuerda si está abierto y todo funciona sin complicaciones. El problema aparece cuando la aplicación crece y varios componentes, alejados entre sí en el árbol, necesitan leer y modificar los mismos datos: el usuario que inició sesión, el contenido del carrito, las preferencias de idioma o una lista de elementos cargada desde una API.
Llegados a ese punto, pasar datos y funciones a través de cinco niveles de componentes se vuelve insostenible, y depurar quién cambió qué y cuándo se convierte en una adivinanza. Redux nació para resolver exactamente eso: centraliza el estado compartido en un único lugar y obliga a que todos los cambios sigan el mismo camino, predecible y fácil de rastrear.
Redux no depende de React. Funciona con cualquier biblioteca de interfaz o incluso sin ninguna, aunque su uso más habitual es junto a React. En esta guía aprenderás primero los conceptos fundamentales con el núcleo de Redux, sin interfaz, para entender qué ocurre por dentro. Después pasarás a la forma recomendada de escribir Redux hoy, Redux Toolkit con React, construyendo una pequeña aplicación de lista de lectura. Usaremos las versiones estables actuales: Redux Toolkit 2.13, React Redux 9.3, React 19.2 y Vite 8.
Los tres principios de Redux
Toda la biblioteca se apoya en tres ideas. Entenderlas hace que el resto del código tenga sentido.
La primera es que existe una única fuente de verdad. Todo el estado global de la aplicación se guarda en un solo objeto dentro de un store. No hay copias dispersas que puedan desincronizarse.
La segunda es que el estado es de solo lectura. Ningún componente modifica el estado directamente. La única forma de cambiarlo es emitir una acción, un objeto que describe lo que ha ocurrido.
La tercera es que los cambios se hacen con funciones puras. Un reducer es una función que recibe el estado actual y una acción, y devuelve el estado siguiente. No modifica el estado recibido, no hace peticiones a la red ni genera valores aleatorios: dadas las mismas entradas, siempre devuelve la misma salida.
El resultado es un flujo de datos en una sola dirección:
Interfaz ──(dispatch de una acción)──► Store
▲ │
│ reducer(estado, acción)
│ │
└──────(notifica el nuevo estado)◄────┘
Este flujo es lo que hace que Redux sea predecible. Si quieres saber por qué el estado tiene un valor concreto, basta con revisar la secuencia de acciones que llegaron al store. Las herramientas de desarrollo de Redux para el navegador muestran exactamente esa secuencia y permiten incluso retroceder en el tiempo para ver el estado en cualquier momento anterior.
Los conceptos básicos sin interfaz
Antes de tocar React, veamos las piezas en un script de JavaScript puro. Crea una carpeta e instala Redux Toolkit, que incluye el núcleo de Redux:
mkdir redux-basico && cd redux-basico
npm init -y
npm pkg set type=module
npm install @reduxjs/toolkit
Empezamos por el reducer. Gestionará el saldo de una hucha:
// hucha.js
import { configureStore } from "@reduxjs/toolkit";
const estadoInicial = { saldo: 0, movimientos: 0 };
function huchaReducer(estado = estadoInicial, accion) {
switch (accion.type) {
case "hucha/depositar":
return {
saldo: estado.saldo + accion.payload,
movimientos: estado.movimientos + 1,
};
case "hucha/retirar":
if (accion.payload > estado.saldo) {
return estado;
}
return {
saldo: estado.saldo - accion.payload,
movimientos: estado.movimientos + 1,
};
default:
return estado;
}
}
Fíjate en varios detalles. El parámetro estado = estadoInicial define el valor por defecto: la primera vez que Redux llama al reducer, el estado es undefined y se usa el inicial. Cada caso devuelve un objeto nuevo en lugar de modificar el existente; esa es la regla de inmutabilidad. Y el caso default devuelve el estado sin cambios, porque todos los reducers reciben todas las acciones y deben ignorar las que no les corresponden.
Las acciones son objetos planos con una propiedad obligatoria, type, que describe el evento. La convención actual es usar el formato dominio/evento, como hucha/depositar. Los datos adicionales se colocan, por convención, en la propiedad payload.
Ahora creamos el store, nos suscribimos a sus cambios y despachamos algunas acciones:
// hucha.js (continuación)
const store = configureStore({ reducer: huchaReducer });
const cancelar = store.subscribe(() => {
console.log("Nuevo estado:", store.getState());
});
store.dispatch({ type: "hucha/depositar", payload: 50 });
store.dispatch({ type: "hucha/depositar", payload: 20 });
store.dispatch({ type: "hucha/retirar", payload: 100 });
store.dispatch({ type: "hucha/retirar", payload: 30 });
cancelar();
store.dispatch({ type: "hucha/depositar", payload: 5 });
console.log("Estado final:", store.getState());
node hucha.js
Nuevo estado: { saldo: 50, movimientos: 1 }
Nuevo estado: { saldo: 70, movimientos: 2 }
Nuevo estado: { saldo: 70, movimientos: 2 }
Nuevo estado: { saldo: 40, movimientos: 3 }
Estado final: { saldo: 45, movimientos: 4 }
Estas son las cuatro operaciones del store. dispatch envía una acción y es la única forma de cambiar el estado. getState devuelve el estado actual. subscribe registra una función que se ejecuta tras cada despacho y devuelve otra función para cancelar la suscripción, por eso el último depósito ya no se imprime. El retiro de 100 no cambia el saldo porque el reducer lo rechaza, pero la suscripción se ejecuta igualmente: Redux notifica cada despacho, no solo los que producen cambios.
Por qué ya no se usa createStore
Si consultas tutoriales antiguos, verás que crean el store con la función createStore del paquete redux. Desde Redux 5, esa función está marcada como obsoleta: sigue funcionando, pero el editor la muestra tachada y la documentación recomienda configureStore de Redux Toolkit. Existe un alias, legacy_createStore, para quien necesite mantener código antiguo sin avisos.
El motivo no es caprichoso. Escribir Redux “a mano” exigía mucho código repetitivo y era fácil cometer errores: mutar el estado por accidente, olvidar el caso por defecto o configurar mal las herramientas de desarrollo. configureStore resuelve todo eso con una sola llamada:
- Activa automáticamente la extensión Redux DevTools del navegador.
- Añade el middleware para acciones asíncronas.
- En desarrollo, incluye comprobaciones que detectan mutaciones accidentales del estado y valores no serializables, como funciones o instancias de clases, dentro del store.
Redux Toolkit es la forma oficial y recomendada de escribir Redux. Todo lo que viste en la sección anterior sigue siendo válido, porque Redux Toolkit no cambia los conceptos, solo elimina el trabajo repetitivo.
Crear el proyecto con React y Vite
Vamos a construir una aplicación de lista de lectura donde se pueden añadir libros, marcarlos como leídos, filtrarlos y cargar recomendaciones desde una API. Create React App, que usaban muchos tutoriales, fue declarado obsoleto por el equipo de React; la opción estándar para una aplicación de cliente es Vite. Crea el proyecto con la plantilla de React y TypeScript:
npm create vite@latest lista-lectura -- --template react-ts
cd lista-lectura
npm install
npm install @reduxjs/toolkit react-redux
La estructura que construiremos es esta:
lista-lectura/
├── src/
│ ├── app/
│ │ ├── store.ts
│ │ └── hooks.ts
│ ├── features/
│ │ ├── libros/
│ │ │ ├── librosSlice.ts
│ │ │ ├── ListaLibros.tsx
│ │ │ └── FormularioLibro.tsx
│ │ └── filtro/
│ │ └── filtroSlice.ts
│ ├── App.tsx
│ └── main.tsx
├── package.json
└── vite.config.ts
Organizar el código por funcionalidades, con una carpeta por cada parte del estado, es la estructura que recomienda la documentación de Redux. Todo lo relacionado con los libros, su estado, sus acciones y sus componentes, vive junto.
Slices: estado, reducers y acciones en un solo lugar
Un slice es una porción del estado global junto con los reducers que la modifican. La función createSlice genera automáticamente los creadores de acciones a partir de los reducers, de modo que no tienes que escribir los tipos de acción ni los objetos a mano.
Crea src/features/libros/librosSlice.ts:
import { createSlice, nanoid, type PayloadAction } from "@reduxjs/toolkit";
export interface Libro {
id: string;
titulo: string;
autor: string;
leido: boolean;
}
interface LibrosState {
elementos: Libro[];
}
const estadoInicial: LibrosState = {
elementos: [
{ id: "1", titulo: "Rayuela", autor: "Julio Cortázar", leido: true },
{ id: "2", titulo: "Pedro Páramo", autor: "Juan Rulfo", leido: false },
],
};
const librosSlice = createSlice({
name: "libros",
initialState: estadoInicial,
reducers: {
libroAgregado: {
reducer(estado, accion: PayloadAction<Libro>) {
estado.elementos.push(accion.payload);
},
prepare(titulo: string, autor: string) {
return {
payload: { id: nanoid(), titulo, autor, leido: false },
};
},
},
lecturaAlternada(estado, accion: PayloadAction<string>) {
const libro = estado.elementos.find((l) => l.id === accion.payload);
if (libro) {
libro.leido = !libro.leido;
}
},
libroEliminado(estado, accion: PayloadAction<string>) {
estado.elementos = estado.elementos.filter(
(l) => l.id !== accion.payload
);
},
},
});
export const { libroAgregado, lecturaAlternada, libroEliminado } =
librosSlice.actions;
export default librosSlice.reducer;
Hay algo en este código que parece contradecir lo que aprendiste: estado.elementos.push(...) y libro.leido = !libro.leido modifican el estado directamente. Esto funciona porque createSlice usa internamente la biblioteca Immer. Tu reducer recibe un borrador, una versión especial del estado que registra los cambios que haces sobre él, y al terminar Immer produce un objeto nuevo e inmutable con esos cambios aplicados. Escribes código que parece mutable y obtienes actualizaciones inmutables.
Esta comodidad tiene una condición: solo funciona dentro de createSlice y createReducer. En cualquier otro lugar de tu aplicación, mutar el estado sigue siendo un error.
El reducer libroAgregado muestra otra función útil, prepare. Permite que el creador de acciones reciba argumentos cómodos, como el título y el autor, y construya el payload completo, incluido un identificador único generado con nanoid. Generar el identificador aquí, y no dentro del reducer, mantiene el reducer puro.
createSlice genera los creadores de acciones con el nombre de cada reducer y el tipo nombreDelSlice/nombreDelReducer:
console.log(lecturaAlternada("2"));
{ type: 'libros/lecturaAlternada', payload: '2' }
Ahora un segundo slice, más pequeño, para el filtro de visualización en src/features/filtro/filtroSlice.ts:
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
export type Filtro = "todos" | "pendientes" | "leidos";
const filtroSlice = createSlice({
name: "filtro",
initialState: "todos" as Filtro,
reducers: {
filtroCambiado(_estado, accion: PayloadAction<Filtro>) {
return accion.payload;
},
},
});
export const { filtroCambiado } = filtroSlice.actions;
export default filtroSlice.reducer;
Cuando el estado es un valor primitivo, como una cadena, no se puede mutar; en ese caso el reducer simplemente devuelve el nuevo valor.
Configurar el store y los hooks tipados
Une los slices en el store con src/app/store.ts:
import { configureStore } from "@reduxjs/toolkit";
import librosReducer from "../features/libros/librosSlice";
import filtroReducer from "../features/filtro/filtroSlice";
export const store = configureStore({
reducer: {
libros: librosReducer,
filtro: filtroReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Al pasar un objeto en reducer, configureStore combina los reducers: el estado global tendrá una propiedad libros gestionada por el primero y otra filtro gestionada por el segundo. Los tipos RootState y AppDispatch se infieren automáticamente del store, así que nunca tendrás que escribirlos a mano ni mantenerlos sincronizados.
Con esos tipos crearemos versiones tipadas de los hooks de React Redux en src/app/hooks.ts. Desde React Redux 9.1, los hooks tienen un método withTypes que lo hace en una línea:
import { useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "./store";
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
A partir de ahora, en los componentes usarás useAppSelector y useAppDispatch en lugar de los hooks genéricos. El editor conocerá la forma exacta del estado y te avisará si intentas leer una propiedad que no existe.
Por último, haz que el store esté disponible para toda la aplicación envolviéndola con el componente Provider en src/main.tsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./app/store";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
Leer y modificar el estado desde los componentes
Los componentes se conectan al store con dos hooks. useAppSelector recibe una función selectora que extrae una parte del estado; el componente se vuelve a renderizar solo cuando ese valor cambia. useAppDispatch devuelve la función dispatch para enviar acciones.
El formulario para añadir libros, en src/features/libros/FormularioLibro.tsx:
import { useState, type FormEvent } from "react";
import { useAppDispatch } from "../../app/hooks";
import { libroAgregado } from "./librosSlice";
export function FormularioLibro() {
const dispatch = useAppDispatch();
const [titulo, setTitulo] = useState("");
const [autor, setAutor] = useState("");
function enviar(evento: FormEvent<HTMLFormElement>) {
evento.preventDefault();
if (!titulo.trim() || !autor.trim()) return;
dispatch(libroAgregado(titulo.trim(), autor.trim()));
setTitulo("");
setAutor("");
}
return (
<form onSubmit={enviar}>
<input
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Título"
/>
<input
value={autor}
onChange={(e) => setAutor(e.target.value)}
placeholder="Autor"
/>
<button type="submit">Añadir</button>
</form>
);
}
Observa que los campos del formulario usan useState y no Redux. Esta es una decisión deliberada: el texto que el usuario está escribiendo es estado local que no interesa a ningún otro componente. Solo cuando el libro se envía pasa a formar parte del estado global. Meter en Redux cada pulsación de tecla de un formulario es un error de diseño frecuente.
La lista, en src/features/libros/ListaLibros.tsx:
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { lecturaAlternada, libroEliminado } from "./librosSlice";
import { filtroCambiado, type Filtro } from "../filtro/filtroSlice";
import { seleccionarLibrosVisibles } from "./selectores";
const opciones: Filtro[] = ["todos", "pendientes", "leidos"];
export function ListaLibros() {
const dispatch = useAppDispatch();
const libros = useAppSelector(seleccionarLibrosVisibles);
const filtro = useAppSelector((estado) => estado.filtro);
return (
<section>
<div>
{opciones.map((opcion) => (
<button
key={opcion}
disabled={filtro === opcion}
onClick={() => dispatch(filtroCambiado(opcion))}
>
{opcion}
</button>
))}
</div>
<ul>
{libros.map((libro) => (
<li key={libro.id}>
<label>
<input
type="checkbox"
checked={libro.leido}
onChange={() =>
dispatch(lecturaAlternada(libro.id))
}
/>
{libro.titulo}, de {libro.autor}
</label>
<button
onClick={() => dispatch(libroEliminado(libro.id))}
>
Quitar
</button>
</li>
))}
</ul>
</section>
);
}
El componente no sabe cómo se guarda el estado ni cómo se actualiza. Solo describe qué ocurre, por ejemplo “se alternó la lectura de este libro”, y el reducer decide cómo reflejarlo. Esta separación hace que los componentes sean sencillos y que la lógica de negocio esté concentrada y sea fácil de probar.
Selectores memorizados con createSelector
La lista anterior usa un selector, seleccionarLibrosVisibles, que combina dos partes del estado: los libros y el filtro. Podríamos escribirlo como una función normal, pero hay un problema sutil. Si el selector devuelve un arreglo nuevo con filter en cada llamada, React Redux pensará que el resultado ha cambiado cada vez que se despache cualquier acción, y el componente se volverá a renderizar sin necesidad. En desarrollo, React Redux incluso muestra un aviso en la consola cuando detecta un selector que devuelve referencias distintas para el mismo estado.
La solución son los selectores memorizados. createSelector solo recalcula el resultado cuando cambian sus entradas. Crea src/features/libros/selectores.ts:
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../../app/store";
const seleccionarLibros = (estado: RootState) => estado.libros.elementos;
const seleccionarFiltro = (estado: RootState) => estado.filtro;
export const seleccionarLibrosVisibles = createSelector(
[seleccionarLibros, seleccionarFiltro],
(libros, filtro) => {
if (filtro === "leidos") return libros.filter((l) => l.leido);
if (filtro === "pendientes") return libros.filter((l) => !l.leido);
return libros;
}
);
export const seleccionarProgreso = createSelector(
[seleccionarLibros],
(libros) => {
const leidos = libros.filter((l) => l.leido).length;
return { leidos, total: libros.length };
}
);
Los selectores de entrada extraen datos del estado y la función final calcula el resultado. Mientras los libros y el filtro sean los mismos objetos, seleccionarLibrosVisibles devuelve exactamente el mismo arreglo de la llamada anterior, sin volver a filtrar. Gracias a la inmutabilidad, comprobar si algo ha cambiado es tan barato como comparar referencias.
Una regla práctica: guarda en Redux el estado mínimo y calcula todo lo derivable con selectores. El número de libros leídos, por ejemplo, no debe almacenarse, porque se deduce de la lista y guardarlo por separado crearía dos fuentes de verdad que podrían contradecirse.
Lógica asíncrona con createAsyncThunk
Los reducers deben ser puros, así que no pueden hacer peticiones a una API. Para eso existen los thunks: funciones que reciben dispatch y getState, pueden ejecutar código asíncrono y despachan acciones cuando tienen resultados. configureStore incluye el middleware necesario por defecto.
createAsyncThunk automatiza el patrón más habitual: hacer una petición y despachar una acción al empezar, otra si tiene éxito y otra si falla. Añadamos la carga de recomendaciones al slice de libros. Primero, amplía el estado:
interface LibrosState {
elementos: Libro[];
estadoCarga: "inactivo" | "cargando" | "completado" | "fallido";
error: string | null;
}
Después define el thunk, en el mismo archivo y antes de createSlice:
import { createAsyncThunk } from "@reduxjs/toolkit";
interface Recomendacion {
titulo: string;
autor: string;
}
export const recomendacionesCargadas = createAsyncThunk(
"libros/recomendacionesCargadas",
async (genero: string) => {
const respuesta = await fetch(
`/api/recomendaciones?genero=${encodeURIComponent(genero)}`
);
if (!respuesta.ok) {
throw new Error(
`Error ${respuesta.status} al cargar recomendaciones`
);
}
return (await respuesta.json()) as Recomendacion[];
}
);
createAsyncThunk genera tres tipos de acción a partir del prefijo: libros/recomendacionesCargadas/pending, .../fulfilled y .../rejected. El slice responde a ellas en la sección extraReducers, que sirve para manejar acciones definidas fuera del propio slice:
const librosSlice = createSlice({
name: "libros",
initialState: {
...estadoInicial,
estadoCarga: "inactivo",
error: null,
} as LibrosState,
reducers: {
// ... los reducers anteriores
},
extraReducers: (builder) => {
builder
.addCase(recomendacionesCargadas.pending, (estado) => {
estado.estadoCarga = "cargando";
estado.error = null;
})
.addCase(recomendacionesCargadas.fulfilled, (estado, accion) => {
estado.estadoCarga = "completado";
for (const { titulo, autor } of accion.payload) {
estado.elementos.push({
id: nanoid(),
titulo,
autor,
leido: false,
});
}
})
.addCase(recomendacionesCargadas.rejected, (estado, accion) => {
estado.estadoCarga = "fallido";
estado.error = accion.error.message ?? "Error desconocido";
});
},
});
La notación con builder es la única admitida en Redux Toolkit 2. La antigua forma de objeto, que algunos tutoriales todavía muestran, se eliminó en esta versión. Desde un componente, el thunk se despacha como cualquier acción:
const estadoCarga = useAppSelector((estado) => estado.libros.estadoCarga);
<button
disabled={estadoCarga === "cargando"}
onClick={() => dispatch(recomendacionesCargadas("novela latinoamericana"))}
>
{estadoCarga === "cargando" ? "Cargando..." : "Cargar recomendaciones"}
</button>;
Guardar el estado de carga con valores explícitos, en lugar de varios booleanos como cargando y error, evita combinaciones imposibles, como estar cargando y haber fallado a la vez.
RTK Query para datos del servidor
Si gran parte de tu estado global consiste en datos que vienen de una API, escribir thunks, estados de carga y reducers para cada petición acaba siendo repetitivo. Redux Toolkit incluye RTK Query, una capa especializada en obtener y almacenar en caché datos del servidor:
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { Libro } from "../libros/librosSlice";
export const catalogoApi = createApi({
reducerPath: "catalogoApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
endpoints: (build) => ({
obtenerCatalogo: build.query<Libro[], string>({
query: (genero) => `catalogo?genero=${encodeURIComponent(genero)}`,
}),
}),
});
export const { useObtenerCatalogoQuery } = catalogoApi;
Se registra en el store añadiendo su reducer y su middleware:
export const store = configureStore({
reducer: {
libros: librosReducer,
filtro: filtroReducer,
[catalogoApi.reducerPath]: catalogoApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(catalogoApi.middleware),
});
Y en cualquier componente, una sola línea gestiona la petición, el estado de carga, los errores y la caché:
const { data: catalogo, isLoading, error } = useObtenerCatalogoQuery("ensayo");
Si dos componentes piden el mismo catálogo, RTK Query hace una sola petición y comparte el resultado. La regla general es usar RTK Query para el estado que pertenece al servidor y los slices para el estado propio de la aplicación en el cliente.
Migrar código Redux antiguo de forma gradual
Muchos equipos heredan aplicaciones escritas con el estilo clásico: createStore, constantes de tipos de acción en un archivo, creadores de acciones en otro, reducers con switch y el componente de orden superior connect para enlazar los componentes. No hace falta reescribirlo todo de golpe, porque Redux Toolkit es compatible con ese código y la migración puede hacerse por partes.
El primer paso es sustituir createStore por configureStore, pasándole los mismos reducers. La aplicación sigue funcionando igual, pero ganas las herramientas de desarrollo y las comprobaciones de mutación, que a menudo descubren errores que llevaban tiempo ocultos.
El segundo paso es convertir los reducers uno a uno en slices. Cada slice nuevo reemplaza un reducer, su archivo de constantes y sus creadores de acciones. Mientras los nombres de los tipos de acción coincidan, el resto de la aplicación ni se entera del cambio, así que puedes migrar un dominio por semana sin bloquear el desarrollo.
El tercer paso afecta a los componentes. El patrón de hooks de React Redux con useAppSelector y useAppDispatch sustituye a connect con mucho menos código y mejor inferencia de tipos. connect sigue funcionando, así que basta con usar hooks en los componentes nuevos y migrar los antiguos cuando haya que tocarlos por otro motivo.
Por último, revisa la lógica asíncrona. Si usabas thunks escritos a mano o sagas para peticiones sencillas, createAsyncThunk o RTK Query suelen reemplazarlos con una fracción del código. Las sagas siguen teniendo sentido para flujos complejos de larga duración, pero rara vez son necesarias para cargar y guardar datos.
Errores comunes y cómo resolverlos
Al empezar con Redux hay errores que se repiten. Estos son los más frecuentes.
Mutar el estado fuera de createSlice. Hacer estado.items.push(x) en un reducer escrito a mano, o modificar un objeto obtenido con useAppSelector, rompe la detección de cambios: la interfaz no se actualiza. En desarrollo, el middleware de comprobación de inmutabilidad lanza un error indicando la ruta exacta que se mutó.
Mezclar estilos dentro de un reducer de createSlice. Con Immer puedes mutar el borrador o devolver un valor nuevo, pero no ambas cosas en el mismo reducer. Si haces estado.total = 0 y además return { ...estado }, Immer lanzará un error.
Guardar valores no serializables. Promesas, funciones, instancias de Date o de clases propias rompen las herramientas de depuración y la persistencia. Redux Toolkit muestra un aviso en la consola cuando los detecta. Guarda las fechas como cadenas ISO o marcas de tiempo numéricas.
Selectores que devuelven objetos nuevos. Un selector como (estado) => ({ a: estado.a, b: estado.b }) crea un objeto distinto en cada llamada y provoca renderizados innecesarios. Usa dos selectores separados o createSelector.
Olvidar el Provider. El mensaje “could not find react-redux context value” indica que un componente usa los hooks de React Redux fuera del árbol envuelto por Provider.
Usar la sintaxis de objeto en extraReducers. En Redux Toolkit 2 se eliminó; si migras código antiguo, reescríbelo con la notación builder. El mismo cambio afecta a createReducer.
Llevar todo el estado a Redux. El estado de un formulario en edición, si un desplegable está abierto o el valor de una pestaña activa casi nunca necesitan ser globales. Mantenerlos en los componentes simplifica la aplicación.
Buenas prácticas para proyectos reales
Más allá de evitar errores, estas prácticas mantienen una base de código con Redux sana a medida que crece.
Modela las acciones como eventos, no como comandos. Un nombre como pedidoConfirmado describe algo que ocurrió y permite que varios slices reaccionen a él; establecerTotalACero describe una instrucción concreta y acopla el componente a la estructura del estado.
Normaliza las colecciones grandes. Si guardas cientos de elementos, búscalos por identificador en un objeto en lugar de recorrer un arreglo cada vez. Redux Toolkit incluye createEntityAdapter, que genera la estructura normalizada, los reducers de alta, baja y modificación, y los selectores correspondientes.
Mantén la lógica en los reducers y fuera de los componentes. Cuanto más decide el reducer y menos el componente, más fácil es probar el comportamiento: un reducer se prueba llamándolo con un estado y una acción y comparando el resultado, sin renderizar nada.
import reducer, { lecturaAlternada } from "./librosSlice";
test("alterna el estado de lectura", () => {
const anterior = {
elementos: [
{ id: "7", titulo: "Ficciones", autor: "Borges", leido: false },
],
estadoCarga: "inactivo" as const,
error: null,
};
const siguiente = reducer(anterior, lecturaAlternada("7"));
expect(siguiente.elementos[0].leido).toBe(true);
});
Instala la extensión Redux DevTools en tu navegador. Ver cada acción, el estado antes y después y poder volver a estados anteriores ahorra horas de depuración.
Redux frente a otras alternativas
Redux no es la única forma de gestionar el estado en React, y no siempre es la mejor. Conocer las alternativas te ayudará a elegir con criterio.
El Context API de React, combinado con useReducer, cubre muy bien los casos de estado global sencillo y poco cambiante, como el tema visual o el usuario autenticado. Su limitación es que todos los componentes que consumen un contexto se vuelven a renderizar cuando cambia cualquier parte de su valor.
Zustand ofrece un store global con una API mínima y casi sin código repetitivo. Es una opción excelente para aplicaciones pequeñas y medianas que quieren estado compartido sin la estructura de Redux.
TanStack Query, al igual que RTK Query, se especializa en el estado del servidor: peticiones, caché, reintentos y sincronización. Muchas aplicaciones descubren que, una vez separado el estado del servidor, el estado de cliente restante es tan pequeño que basta con Context o Zustand.
Redux con Redux Toolkit destaca en aplicaciones grandes, con mucho estado compartido que cambia con frecuencia, equipos numerosos que se benefician de una estructura común y necesidad de trazabilidad completa de los cambios. Su arquitectura de acciones y reducers, que en un proyecto pequeño parece excesiva, es precisamente lo que mantiene el orden cuando el proyecto crece.
Conclusiones
Redux se basa en una idea simple: un único store, un estado que solo cambia mediante acciones y reducers puros que calculan el estado siguiente. Ese flujo en una sola dirección es lo que hace que una aplicación grande siga siendo predecible y depurable.
En esta guía has visto esos fundamentos con el núcleo de Redux, despachando acciones y suscribiéndote a los cambios, y después has construido una aplicación con la forma moderna de trabajar: Redux Toolkit 2.13 con configureStore y createSlice, hooks tipados con withTypes de React Redux 9.3, selectores memorizados con createSelector, lógica asíncrona con createAsyncThunk y peticiones con caché mediante RTK Query, todo sobre React 19.2 y Vite 8. También has visto qué ha quedado obsoleto, como createStore, Create React App o la sintaxis de objeto en extraReducers.
La clave para usar Redux bien no es meter todo en el store, sino decidir con criterio qué estado es realmente global. Empieza con un slice, añade más solo cuando varios componentes necesiten compartir datos, y apóyate en las herramientas de desarrollo para ver en todo momento cómo fluye el estado por tu aplicación.
