Inicio / Artículos

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.

Editor: REACTAPP.TOP9 min de lectura2008 palabras

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 con extraReducers modelan los estados de carga, éxito y error.
  • Los valores predeterminados de configureStore() son suficientes para la mayoría de las aplicaciones.

Lecturas relacionadas

  • Dónde deben estar la autenticación, la carga de datos y los errores en una app React: diez soluciones — Diez errores comunes al manejar la autenticación, la carga de datos, los errores y los efectos secundarios del estado en React, y cómo los interceptores, cargadores, cachés de consultas y límites de errores los solucionan.
  • Interceptores de Axios como middleware HTTP: un modelo mental para apps React — Entienda cómo funcionan los interceptores de solicitud y respuesta de Axios como middleware, qué debe ir en cada uno, y cómo centralizan los encabezados de autenticación y el manejo de errores 401 en React.
  • Capas de estado en una app streaming de React: Contexto, Redux Toolkit y RTK Query — Sigue el desarrollo de una interfaz de transmisión de video, desde el uso de prop drilling hasta proveedores anidados, pasando por las slices de Redux Toolkit y RTK Query, y aprende qué tipo de estado corresponde a cada herramienta.