Redux Toolkit, de First Slice a datos asíncronos: un modelo mental funcional
Aprenda cómo se integran el almacén, las rebanadas, los reducidos impulsados por Immer, los selectores y createAsyncThunk de Redux Toolkit, utilizando un contador, un carrito de compras y una lista de productos.
Fuentes: Redux Toolkit: A Complete Guide from Basics to Production
A medida que una aplicación React crece, el estado compartido como el usuario, el carrito, las preferencias, los filtros y los datos de la API se distribuyen en muchas pantallas, y pasar todo esto a través de props se vuelve frágil. Redux resuelve este problema con un almacén central, y Redux Toolkit (RTK) es la forma recomendada de trabajar con Redux en la actualidad. Esta guía va desde un fragmento simple como un contador hasta un carrito con totales derivados y una lista respaldada por la API, para que puedas leer y escribir código con RTK con confianza.
Primero, verifica si realmente necesitas un almacén: el uso de prop-drilling por sí solo no es una razón para instalar Redux.
La idea central detrás de Redux
Redux guarda el estado compartido en un único almacén y lo modifica únicamente a través de acciones gestionadas por los reducers, por lo que cada actualización es predecible. Sin él, los datos se transmiten capa por capa a través de props.
Component
↓
Props
↓
Component
↓
Props
↓
Component
Con una tienda, los componentes leen lo que necesitan y envían acciones para solicitar cambios.
Redux Store
↑
│
┌─────────┴─────────┐
│ │
Component A Component B
│ │
└──── dispatch ─────┘
Qué agrega Redux Toolkit
RTK agrupa utilidades para las tareas comunes de Redux.
Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query
Redux clásico distribuía una sola función en muchos archivos.
Action Types
Action Creators
Reducers
Constants
Store
Middleware
createSlice() permite expresar la mayor parte de eso en un solo lugar.
Cómo fluye los datos en una aplicación RTK
Los componentes leen con useSelector() y envían acciones con useDispatch(); los reducers generan un nuevo estado.
React Application
│
↓
useSelector()
│
↓
Redux Store
↑
│
useDispatch()
↑
│
Actions
│
↓
Reducer
│
↓
New State
El ciclo esencial:
UI
↓
dispatch(action)
↓
Reducer
↓
State update
↓
Subscribed components
↓
UI re-render
Instalación y creación de la tienda
Instale dos paquetes:
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit contiene las APIs de Redux; react-redux las conecta con React.
useSelector()
useDispatch()
Provider
La aplicación de contador de ejemplo está organizada por funcionalidades.
src/
│
├── app/
│ └── store.js
│
├── features/
│ └── counter/
│ └── counterSlice.js
│
├── App.jsx
└── main.jsx
configureStore() recibe un mapa de reducidos de tipo slice; cada clave se convierte en una rama de estado de nivel superior.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
El estado resultante:
{
counter: {
value: 0
}
}
Slices: una funcionalidad, un archivo
Un slice representa una parte lógica del estado, y las aplicaciones suelen tener varios de ellos.
authSlice
cartSlice
productSlice
userSlice
notificationSlice
El slice del contador declara un nombre, un estado inicial y un reducido por operación, y luego exporta los creadores de acciones y el reducido generados.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
},
});
export const {
increment,
decrement,
incrementByAmount,
} = counterSlice.actions;
export default counterSlice.reducer;
Una sola llamada reemplaza tres partes escritas a mano.
Action Types
+
Action Creators
+
Reducer
Qué genera createSlice
Tomemos un slice mínimo:
createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
},
});
RTK combina el nombre del slice y la clave del reducido para formar un tipo de acción
"counter/increment"
y crea un creador de acciones.
increment()
Por lo tanto, dispatch(increment()) es equivalente a enviar:
dispatch({
type: "counter/increment",
});
Vincular el almacén a React
En main.jsx, Provider hace que el almacén esté disponible para cada componente que se encuentre debajo de él.
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App";
import { store } from "./app/store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={store}>
<App />
</Provider>
);
Lectura con useSelector
useSelector() selecciona exactamente la parte del estado que necesita un componente.
import { useSelector } from "react-redux";
function Counter() {
const count = useSelector(
state => state.counter.value
);
return <h1>{count}</h1>;
}
El componente se vuelve a renderizar solo cuando ese valor cambia; consulte cómo decide React-Redux volver a renderizar.
Escribir con useDispatch
useDispatch() devuelve el método dispatch del almacén.
import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";
function Counter() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
);
}
Luego, un clic se procesa de la siguiente manera:
Button Click
↓
dispatch(increment())
↓
Action
↓
Reducer
↓
State changes
↓
useSelector detects relevant change
↓
Component re-renders
Transmisión de datos con payload
Las acciones transportan datos en payload, que se obtiene del segundo argumento del reductor:
incrementByAmount(state, action) {
state.value += action.payload;
}
El llamante pasa el valor:
dispatch(incrementByAmount(10));
lo que genera esta acción:
{
type: "counter/incrementByAmount",
payload: 10
}
Cada acción tiene un type y, opcionalmente, un payload.
action
│
├── type
└── payload
Por qué se permite mutar el estado aquí
Una pregunta clásica en las entrevistas: este reducer parece mutar el estado, lo cual Redux prohíbe.
increment(state) {
state.value += 1;
}
RTK ejecuta los reducers a través de Immer, el cual proporciona un proxy provisional, registra los cambios y genera un nuevo objeto inmutable.
Reducer
↓
Immer Proxy
↓
Track changes
↓
Create new immutable state
Se obtiene código legible sin violar la inmutabilidad. Se debe modificar el draft o devolver un nuevo valor, nunca ambos, y no se debe mutar de esta manera fuera de los reducers de RTK.
Una porción del carrito de compras
Un carrito de comercio electrónico comienza vacío.
const initialState = {
items: [],
};
La porción permite agregar, eliminar y borrar artículos; removeItem asigna un nuevo array, que Immer también acepta.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
items: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem(state, action) {
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
},
clearCart(state) {
state.items = [];
},
},
});
export const {
addItem,
removeItem,
clearCart,
} = cartSlice.actions;
export default cartSlice.reducer;
Agregar un artículo
Dado un objeto de producto:
const product = {
id: 101,
name: "Laptop",
price: 60000,
};
Un manejador de clic lo envía:
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
y todo el producto se convierte en la carga útil.
{
type: "cart/addItem",
payload: {
id: 101,
name: "Laptop",
price: 60000
}
}
Luego el reductor lo coloca en:
state.items
Eliminar un elemento
Filtros de eliminación por id:
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
}
El componente solo pasa el id:
dispatch(removeItem(101));
así que dentro del reductor:
action.payload === 101
Derivar valores en lugar de almacenarlos
Supongamos que el carrito contiene estas entradas:
[
{
price: 100,
quantity: 2,
},
{
price: 50,
quantity: 3,
}
]
El total se calcula directamente a partir de ellas:
100 × 2 + 50 × 3
= 200 + 150
= 350
Es tentador almacenar el total junto a los elementos:
{
items,
total
}
pero entonces cada reductor debería mantenerlos sincronizados. Mejor calcularlo directamente:
const selectCartTotal = state =>
state.cart.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
y leerlo después:
const total = useSelector(selectCartTotal);
Almacene la fuente de verdad; derive todo lo que sea calculable a partir de ella.
Seleccionadores como punto de acceso único
Un selector es una función que lee el estado.
const selectCartItems = state =>
state.cart.items;
Los componentes lo utilizan en lugar de conocer la forma del estado.
const items = useSelector(selectCartItems);
De lo contrario, este camino se repite en todas partes:
state.cart.items
Con un selector, la reestructuración afecta a una sola función.
Memorización con createSelector
createSelector() almacena en caché un resultado derivado hasta que cambien sus entradas.
import { createSelector } from "@reduxjs/toolkit";
const selectCartItems = state =>
state.cart.items;
export const selectCartTotal = createSelector(
[selectCartItems],
items =>
items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
)
);
La lógica es sencilla:
Input unchanged
↓
Return cached result
Input changed
↓
Recalculate
Esto ayuda con cálculos costosos y con selectores que devuelven nuevos arrays u objetos, los cuales de lo contrario se volverían a renderizar con cada actualización del almacén.
Carga de datos con createAsyncThunk
Las aplicaciones llaman a APIs como:
GET /api/products
Una solicitud pasa de estado inactivo a estado de carga hasta llegar al estado de éxito
idle
↓
loading
↓
success
o a un estado de error.
idle
↓
loading
↓
error
createAsyncThunk() modela esto: se le proporciona un prefijo de tipo y una función asíncrona; lanzar una excepción indica que la solicitud falló.
import {
createAsyncThunk,
createSlice,
} from "@reduxjs/toolkit";
export const fetchProducts = createAsyncThunk(
"products/fetchProducts",
async () => {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
);
Genera tres acciones de ciclo de vida:
fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected
Manejo del ciclo de vida en un slice
El slice de productos hace seguimiento de los elementos, el estado y los errores, actualizándolos en extraReducers.
const productsSlice = createSlice({
name: "products",
initialState: {
items: [],
status: "idle",
error: null,
},
reducers: {},
extraReducers: builder => {
builder
.addCase(
fetchProducts.pending,
state => {
state.status = "loading";
}
)
.addCase(
fetchProducts.fulfilled,
(state, action) => {
state.status = "success";
state.items = action.payload;
}
)
.addCase(
fetchProducts.rejected,
(state, action) => {
state.status = "failed";
state.error =
action.error.message;
}
);
},
});
Reducers versus extraReducers
reducers define las acciones propias del slice, junto con sus creadores generados.
reducers: {
addItem() {}
}
extraReducers responde a acciones definidas en otro lugar, como las provenientes de createAsyncThunk():
pending
fulfilled
rejected
El slice solo las escucha.
Envío del thunk desde un componente
Inicia la solicitud al montar el componente:
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
El Modo Estricto ejecuta los efectos dos veces en el entorno de desarrollo, por lo que se esperan dos solicitudes allí. Luego selecciona el estado
const {
items,
status,
error,
} = useSelector(
state => state.products
);
y renderiza según el estado:
if (status === "loading") {
return <p>Loading...</p>;
}
if (status === "failed") {
return <p>{error}</p>;
}
return (
<div>
{items.map(product => (
<div key={product.id}>
{product.name}
</div>
))}
</div>
);
Para la obtención de datos más compleja, RTK Query puede reemplazar este patrón.
Middleware y configureStore
El middleware se encuentra entre dispatch() y el reductor.
dispatch(action)
↓
Middleware
↓
Reducer
↓
New State
Usos comunes:
- Lógica asíncrona
- Registro de logs
- Análisis de datos
- Flujos de autenticación
- Efectos secundarios
- Intercepción de acciones
configureStore() sustituye la configuración manual, agregando middleware predeterminado (incluidos thunks) y Redux DevTools.
const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
products: productsReducer,
},
});
Puntos clave
- Un slice agrupa el estado, los reductores y los creadores de acciones generados para una función específica.
- Immer hace que los reductores de tipo mutación sean seguros, pero solo dentro de los reductores RTK.
- Almacene solo el estado esencial; obtenga el resto con selectores, memorizándolos cuando sea costoso hacerlo.
createAsyncThunk()junto conextraReducersmodelan los estados de carga, éxito y error.- Los valores predeterminados de
configureStore()son suficientes para la mayoría de las aplicaciones.
Lecturas relacionadas
- De los scriptes del DOM a la UI como función del estado: el modelo central de React — Por qué React reemplaza las actualizaciones manuales del DOM por componentes declarativos, y cómo JSX, props, estado, volver a renderizar y el flujo de datos unidireccional encajan en un mismo modelo mental.
- Enviando datos con RTK Query: un guia practico sobre mutaciones — Aprenda cómo usar builder.mutation() en RTK Query para enviar solicitudes POST, gestionar los estados de carga y error, y crear un componente de formulario funcional.