createAsyncThunk: pending, fulfilled y rejected automáticos
createAsyncThunk envuelve una función async y le da un ciclo de vida serializable: despacha por ti las acciones pending, fulfilled y rejected, que tú manejas en extraReducers para mantener el trío status, datos y error. Esta lección explica el payloadCreator y su thunkAPI —dispatch, getState, signal, rejectWithValue—, cómo tipar el thunk con sus tres parámetros genéricos, cómo evitar peticiones duplicadas con la opción condition y cómo cancelar con AbortSignal. Termina situando el thunk en su lugar honesto de 2026: excelente para efectos puntuales, pero la versión manual de una cache cuando lo usas para todo el estado de servidor, que es el trabajo de RTK Query.
La asincronía fue siempre el punto donde Redux se ponía incómodo: un reducer debe ser puro y síncrono, así que una petición a la red no cabe dentro. La respuesta clásica eran los thunks a mano, funciones que reciben dispatch y despachan acciones en distintos momentos del ciclo de una promesa, con el tedio de inventar y manejar tres acciones por cada operación. createAsyncThunk industrializa ese patrón: le das una función async y él genera y despacha por ti tres acciones —pending al empezar, fulfilled al resolver, rejected al fallar— que tú solo tienes que atender. El resultado es que la asincronía deja de ser un caso especial artesanal y se convierte en una máquina de tres estados que Redux te regala hecha, con la promesa domada en acciones inspeccionables.
- Crear un thunk con
createAsyncThunky entender las tres acciones que despacha por su cuenta. - Manejar
pending,fulfilledyrejectedenextraReducerspara el tríostatus, datos y error. - Usar
thunkAPI:rejectWithValuepara errores tipados,signalpara cancelar ygetStatepara decidir. - Tipar el thunk con sus tres genéricos y evitar peticiones duplicadas con la opción
condition.
Un thunk que despacha su propio ciclo de vida
createAsyncThunk recibe dos cosas: un prefijo de tipo, como usuario/cargar, y un payload creator, la función async que hace el trabajo. A partir del prefijo genera tres action creators anidados —cargarUsuario.pending, cargarUsuario.fulfilled y cargarUsuario.rejected— y devuelve un thunk que, al despacharse, los emite en el orden que corresponda: pending en cuanto arranca, y luego fulfilled con el valor resuelto o rejected con el error. El payload creator recibe el argumento que le pasaste al despachar y un objeto thunkAPI lleno de utilidades.
import { createAsyncThunk } from "@reduxjs/toolkit";
interface Usuario { id: string; nombre: string }
export const cargarUsuario = createAsyncThunk(
"usuario/cargar",
async (id: string, thunkAPI) => {
const res = await fetch(`/api/usuarios/${id}`, { signal: thunkAPI.signal });
if (!res.ok) return thunkAPI.rejectWithValue("no encontrado");
return (await res.json()) as Usuario; // esto viaja en fulfilled.payload
},
);
Reaccionar al ciclo en extraReducers
El thunk no sabe nada de tu estado; solo despacha acciones. Eres tú quien decide qué significan, y lo haces en extraReducers del slice, con el builder atendiendo cada fase del ciclo. El patrón canónico mantiene tres campos: un status que recorre idle, loading, succeeded y failed; los datos cuando llegan; y el error cuando lo hay. Ese trío es la representación honesta de cualquier operación asíncrona, y createAsyncThunk te da los tres momentos exactos en que actualizarlo.
import { createSlice } from "@reduxjs/toolkit";
import { cargarUsuario } from "./thunks";
interface Estado {
usuario: Usuario | null;
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
}
const inicial: Estado = { usuario: null, status: "idle", error: null };
const slice = createSlice({
name: "usuario",
initialState: inicial,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(cargarUsuario.pending, (e) => {
e.status = "loading";
e.error = null;
})
.addCase(cargarUsuario.fulfilled, (e, accion) => {
e.status = "succeeded";
e.usuario = accion.payload;
})
.addCase(cargarUsuario.rejected, (e, accion) => {
e.status = "failed";
e.error = (accion.payload as string) ?? accion.error.message ?? "error";
});
},
});
flowchart LR A[idle] -->|dispatch del thunk| B[pending loading] B -->|promesa resuelta| C[fulfilled succeeded] B -->|promesa rechazada| D[rejected failed] C -->|volver a despachar| B D -->|reintentar| B style B fill:#fab387,color:#11111b style C fill:#a6e3a1,color:#11111b style D fill:#f38ba8,color:#11111b
thunkAPI: errores tipados, cancelación y guardas
El objeto thunkAPI es la caja de herramientas del payload creator. rejectWithValue te deja rechazar con un valor propio y tipado que llega a rejected.payload, en lugar de depender del error.message genérico: así distingues un 404 de un fallo de red con datos estructurados. signal es un AbortSignal que RTK aborta si cancelas el thunk, y pasarlo a fetch hace que la petición se corte de verdad. getState y dispatch te dejan leer o encadenar. Y la opción condition se evalúa antes de arrancar: si devuelve false, el thunk ni siquiera empieza, lo que evita relanzar una petición que ya está en curso.
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "./store";
export const cargarUsuario = createAsyncThunk<
Usuario, // tipo del valor en fulfilled
string, // tipo del argumento
{ state: RootState; rejectValue: string } // tipos de thunkAPI
>(
"usuario/cargar",
async (id, { rejectWithValue, signal }) => {
const res = await fetch(`/api/usuarios/${id}`, { signal });
if (!res.ok) return rejectWithValue("no encontrado");
return (await res.json()) as Usuario;
},
{
condition: (id, { getState }) => {
// no relances si esa carga ya esta en curso
return getState().usuario.status !== "loading";
},
},
);
rejectWithValue
Rechaza con un valor propio y tipado que llega a rejected.payload, para distinguir errores en vez del error.message genérico.
signal
Un AbortSignal que RTK aborta al cancelar el thunk; pásalo a fetch para cortar la petición de verdad.
condition
Se evalúa antes de arrancar; si devuelve false, el thunk no empieza. Es la guarda contra peticiones duplicadas.
getState y dispatch
Leer el estado para decidir y encadenar otras acciones o thunks desde dentro del payload creator.
Hay dos niveles de cancelación y conviene no confundirlos. El barato es dejar de escuchar el resultado; la petición sigue viva por la red y solo tiras su respuesta. El bueno es abortarla: si pasas el signal de thunkAPI a fetch —o a cualquier API que respete AbortSignal—, cuando RTK cancela el thunk la conexión se corta de verdad y liberas recursos del servidor y del cliente. En listas con búsqueda incremental o en vistas que se desmontan a mitad de carga, abortar de verdad es la diferencia entre una app que respeta la red y una que la satura con peticiones zombis.
createAsyncThunk es excelente para efectos puntuales: enviar un formulario, disparar una acción con confirmación, orquestar una secuencia. Pero si te descubres escribiendo el mismo trío pending, fulfilled, rejected para cada endpoint de lectura, con su status y su error copiados una y otra vez, estás construyendo a mano una cache: deduplicación, reintento, invalidación, caducidad. Ese es exactamente el trabajo que RTK Query automatiza. La regla práctica: thunks para acciones y efectos; RTK Query para leer y escribir datos del servidor de forma sistemática.
El problema profundo que resuelve createAsyncThunk no es escribir menos código, sino reconciliar dos mundos con leyes distintas: el de Redux, donde todo cambio es una acción síncrona, serializable y registrada, y el de las promesas, donde el tiempo transcurre entre que pides algo y lo recibes y cualquier cosa puede fallar por el camino. Un reducer no puede esperar; una promesa no puede evitar hacerlo. createAsyncThunk tiende el puente proyectando el ciclo de vida de la promesa sobre tres instantes discretos y despachando en cada uno una acción, de modo que el paso del tiempo asíncrono queda descrito como una secuencia de eventos que el store entiende y las DevTools pueden rebobinar. Esto tiene una consecuencia conceptual que va más allá de Redux: toda operación asíncrona es en el fondo una pequeña máquina de estados de tres nodos —en curso, resuelta, fallida— y fingir lo contrario es lo que produce los bugs clásicos del spinner que no se apaga o del error que no se muestra. Al obligarte a manejar pending, fulfilled y rejected por separado, createAsyncThunk no te impone ceremonia: te impide olvidar un estado que siempre existió, aunque tu código anterior lo ignorara. Y ahí asoma su propia frontera. Si esa máquina de tres estados la repites idéntica para cada lectura de datos del servidor, has empezado a reimplementar una cache a mano, con su caducidad y su invalidación reinventadas en cada slice. Reconocer ese momento —cuando el thunk deja de ser una herramienta y se vuelve un patrón copiado— es reconocer que el estado de servidor no era estado de Redux, y que la siguiente lección existe precisamente para eso.
- Escribe un
createAsyncThunkpara cargar un recurso y manejapending,fulfilledyrejectedenextraReducerscon el tríostatus, datos y error. - Renderiza el trío en la UI: un spinner en
loading, los datos ensucceededy un mensaje enfailed; confirma que el spinner siempre se apaga. - Añade
rejectWithValuecon un objeto de error tipado que distinga un 404 de un fallo de red, y muéstralos distinto en la vista. - Pasa el
signalafetchy provoca una cancelación desmontando el componente a mitad de carga; verifica en la pestaña de red que la petición se aborta. - Añade una
conditionque impida relanzar la carga mientrasstatusesloadingy demuestra que dos despachos rápidos solo producen una petición. - Cuenta cuántos endpoints de tu app repiten este mismo trío y argumenta cuáles deberían migrar a
RTK Query.