redux-thunk: despachar funciones para la asincronía simple
La primera necesidad que rompe el modelo de Redux es la asincronía: dispatch solo acepta objetos planos y el reducer es síncrono. redux-thunk responde con una jugada minúscula: permite despachar también funciones. Un thunk es una computación diferida que, al ser interceptada, se ejecuta recibiendo dispatch y getState, y ahí puede esperar promesas, leer el estado y despachar el trío cargando-éxito-error. Esta lección lee el middleware entero —cabe en una línea—, escribe un thunk a mano y luego lo eleva a createAsyncThunk, el enfoque asíncrono por defecto de RTK, con su thunkAPI, su cancelación por signal y su rejectWithValue. Cierra marcando el techo del thunk: es imperativo, y por eso ideal para el recado, insuficiente para la conversación.
La primera necesidad que revienta el modelo de Redux es la asincronía. El dispatch solo acepta objetos planos y el reducer es rigurosamente síncrono, así que ¿cómo despachas el resultado de un fetch que todavía no ha llegado? redux-thunk responde con una jugada minúscula y elegante: permite despachar también funciones. Una función así —un thunk— es una computación aplazada que, cuando el middleware la intercepta, se ejecuta recibiendo dispatch y getState. De pronto tienes un lugar donde esperar promesas, leer el estado actual y despachar varias acciones en secuencia —cargando, éxito, error— sin ensuciar ni el reducer ni el componente. Es el enfoque asíncrono por defecto de Redux Toolkit, y su virtud es también su frontera: es imperativo, directo y sin ceremonia, perfecto mientras el flujo sea un recado con principio y fin.
- Entender qué es un thunk: una computación diferida envuelta en una función.
- Leer el middleware
thunkentero y ver por qué cabe en una sola expresión. - Despachar lógica asíncrona a mano con el trío
pending,fulfilled,rejected. - Usar
createAsyncThunkde RTK con suthunkAPI, la cancelación porsignalyrejectWithValue.
El thunk: una computación diferida
La palabra viene de la implementación de Algol 60: un thunk era el fragmento de código que el compilador generaba para calcular un argumento pasado por nombre, aplazado hasta que de verdad se usara. La idea sobrevivió en programación funcional como una función sin argumentos —o casi— que envuelve un cálculo para ejecutarlo más tarde, como () => 40 + 2 en lugar del 42 ya cocinado. Redux toma prestado exactamente ese gesto: en vez de despachar el valor —un objeto de acción ya formado—, despachas la receta que producirá valores luego —una función—. El aplazamiento no es un detalle, es el mecanismo entero: ese hueco temporal es donde cabe la espera de una promesa.
El middleware que lo hace posible es célebre por su tamaño; cabe casi en un tuit y no esconde nada.
import type { Dispatch } from "@reduxjs/toolkit";
// El middleware thunk, entero: si la accion es una funcion, la ejecuta.
const thunk =
({ dispatch, getState }: { dispatch: Dispatch; getState: () => unknown }) =>
(next: Dispatch) =>
(action: unknown) =>
typeof action === "function"
? action(dispatch, getState) // es un thunk: lo corremos y devolvemos su retorno
: next(action); // es un objeto plano: sigue por la cadena
Toda la funcionalidad está en esa bifurcación: si lo que llega es una función, el thunk la llama pasándole dispatch y getState y devuelve lo que la función devuelva —por eso puedes hacer await dispatch(unThunk())—; si es un objeto plano, lo deja continuar. No hay más. Lo que el usuario percibe como “Redux ahora es asíncrono” no es un motor nuevo, es un dispatch sobrecargado para aceptar funciones además de objetos. Esa economía —añadir una capacidad enorme con una rama de nueve líneas— es la razón por la que el thunk fue durante años el patrón asíncrono canónico y sigue siendo el punto de partida.
Despachar lógica asíncrona a mano
Escrito a mano, un thunk es una función que retorna otra función; la interior recibe dispatch y getState y orquesta el flujo. El patrón universal es el trío: despacha una acción de cargando, espera la promesa, y despacha éxito o error según el desenlace. Con getState a mano puedes además cortar el efecto antes de empezar —no vuelvas a pedir un usuario que ya tienes—, y devolver la promesa deja que el componente haga await si necesita encadenar.
import type { ThunkAction, UnknownAction } from "@reduxjs/toolkit";
import type { RootState } from "./store";
export const cargarUsuario =
(id: string): ThunkAction<Promise<void>, RootState, unknown, UnknownAction> =>
async (dispatch, getState) => {
if (getState().usuario.datos?.id === id) return; // ya lo tenemos, no repitas
dispatch({ type: "usuario/cargando" });
try {
const r = await fetch(`/api/usuarios/${id}`);
dispatch({ type: "usuario/exito", payload: await r.json() });
} catch (e) {
dispatch({ type: "usuario/error", payload: String(e) });
}
};
flowchart TD A[dispatch del thunk] --> P[se despacha cargando] P --> Q[corre la funcion async] Q -->|resuelve| F[se despacha exito] Q -->|lanza o rechaza| R[se despacha error] F --> S[reducer pasa a listo] R --> T[reducer pasa a error] style P fill:#f9e2af,color:#11111b style F fill:#a6e3a1,color:#11111b style R fill:#f38ba8,color:#11111b
createAsyncThunk: el thunk que RTK genera
Escribir ese trío a mano una y otra vez es repetitivo y propenso a olvidos, así que Redux Toolkit lo codificó. createAsyncThunk recibe un prefijo de tipo y un payload creator asíncrono, y a cambio genera tres creadores de acción —.pending, .fulfilled, .rejected— y los despacha automáticamente alrededor de tu promesa. Tú solo escribes la parte interesante —la petición— y manejas los tres desenlaces en extraReducers. El segundo argumento del creador, el thunkAPI, expone dispatch, getState, un signal de tipo AbortSignal para cancelar, rejectWithValue para devolver errores tipados y condition para abortar antes de empezar.
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
export const cargarUsuario = createAsyncThunk(
"usuario/cargar",
async (id: string, thunkAPI) => {
const r = await fetch(`/api/usuarios/${id}`, { signal: thunkAPI.signal });
if (!r.ok) return thunkAPI.rejectWithValue("no encontrado");
return (await r.json()) as { nombre: string };
},
);
const slice = createSlice({
name: "usuario",
initialState: { datos: null, estado: "inactivo" } as {
datos: { nombre: string } | null;
estado: "inactivo" | "cargando" | "listo" | "error";
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(cargarUsuario.pending, (s) => void (s.estado = "cargando"))
.addCase(cargarUsuario.fulfilled, (s, a) => {
s.estado = "listo";
s.datos = a.payload;
})
.addCase(cargarUsuario.rejected, (s) => void (s.estado = "error"));
},
});
Dos matices sobre la cancelación evitan falsas expectativas. thunkAPI.signal cancela la petición —el fetch aborta— pero no detiene el JavaScript ya en marcha: si el payload creator hace tres cosas seguidas, abortar no rebobina las dos primeras. La cancelación de un thunk es superficial, no cooperativa como la de un generador que se pausa entre yield. Y thunkAPI.condition deja abortar antes de empezar: devuelve false y el thunk ni siquiera despacha pending, ideal para deduplicar cargas ya en vuelo. Entre signal y condition cubres el “no empieces si sobra” y el “corta la petición que ya no interesa”, pero ninguno te da el take-latest fino que sí regalan la saga y el listener de las próximas lecciones.
dispatch
Despachar más acciones desde dentro del efecto, encadenando thunks o notificando a otras rebanadas del store.
getState
Leer el estado completo para decidir sobre el mundo actual: guardas, deduplicación, ramas según lo ya cargado.
signal
Un AbortSignal que se dispara al abortar el thunk; pásalo a fetch y la petición en vuelo se cancela de verdad.
rejectWithValue
Devolver un error como payload tipado en .rejected en vez de una excepción opaca, para que el reducer lo lea limpio.
Antes de que existiera RTK Query, la gente escribía thunks para leer del servidor, y así reimplementaba a mano la caché, la deduplicación de peticiones y la revalidación una y otra vez. En 2026 ese estado de servidor no es tu problema: vive en RTK Query o en TanStack Query, que te lo regalan. Reserva createAsyncThunk para la asincronía de comando —enviar un formulario, ejecutar una mutación con orquestación local, disparar un efecto que no es una simple lectura cacheable—. Si te descubres escribiendo un thunk que solo hace GET y guarda el resultado, casi seguro estás reinventando una caché que ya existe.
El middleware de nueve líneas revela algo profundo: la asincronía en Redux nunca fue un mecanismo nuevo, solo un dispatch que aprende que una función también es un valor. El thunk no extiende Redux, explota una rendija —la diferencia entre “despachas un valor” y “una función también lo es”— y por eso es el efecto menos mágico que existe: lo que lees es lo que corre, de arriba abajo, imperativamente, sin planificador ni cola oculta que intermedie. Esa literalidad es su mayor don en el noventa por ciento de los casos, porque un recado asíncrono —pide, espera, guarda— se lee como la prosa que es. Pero la misma literalidad es su techo: un thunk es un guión en línea recta y carece de vocabulario para “cancela el anterior cuando empiece uno nuevo”, “agrupa las ráfagas”, “espera a que ocurra aquella otra acción” o “reintenta con espera creciente”. Puedes atornillar todo eso con banderas, refs y contadores, pero cada tornillo es una reimplementación artesanal de control de flujo que la próxima lección, saga, expresa como un valor de primera clase. Así que el thunk no es una herramienta inferior, sino una de forma distinta: es la respuesta correcta siempre que el efecto sea un recado autocontenido, y la respuesta equivocada en el instante en que el efecto se convierte en una conversación entre eventos que se cruzan, se cancelan y se esperan. Saber dónde cae esa frontera —recado frente a conversación— es la mayor parte del juicio que este nivel quiere sembrar en ti, y todo lo demás son detalles de sintaxis.
- Copia el middleware
thunkde nueve líneas y compruébalo: despachar una función la ejecuta; despachar un objeto plano la deja pasar al reducer. - Escribe un thunk a mano con
cargando,exito,errory usagetStatepara saltarte elfetchsi el dato ya está cargado. - Migra ese thunk a
createAsyncThunky maneja los tres casos enextraReducers; confirma que la máquina de estados de la rebanada es idéntica. - Cablea
thunkAPI.signaldentro defetchy cancela una petición lenta desmontando el componente; observa que sale por.rejectedcon motivo de aborto. - Intenta expresar “quédate solo con la última” —cancelar la carga anterior cuando llega un id nuevo— usando únicamente thunk, anota cuánta contabilidad manual cuesta, y reserva ese problema para la lección de saga.