Thunks tipados y estados de carga bien modelados
createAsyncThunk acepta tres parametros de tipo: el valor resuelto, el argumento y un objeto de configuracion donde se declaran el estado, el dispatch, el extra y el valor de rechazo. Esta leccion explica que se pierde cuando se omiten, por que getState devuelve unknown sin declarar el estado y por que el payload de rejected es opcional aunque uses rejectWithValue. Construye un thunk preconfigurado del proyecto con withTypes, tipa los tres casos en extraReducers y termina sustituyendo el trio de campos status datos y error por una union discriminada que hace irrepresentables los estados imposibles.
La asincronía es donde el tipado de Redux se vuelve interesante, porque hay más de un tipo en juego y todos son distintos: el argumento con que se invoca el thunk, el valor que devuelve al resolverse, el estado que puede leer por el camino y el error que produce al fallar. createAsyncThunk los admite todos, pero como parámetros opcionales, y esa opcionalidad es una trampa amable: el thunk compila sin declarar nada y solo más tarde descubres que getState te da un valor desconocido y que el payload de rechazo no tiene forma. Tipar bien un thunk es declarar esos cuatro tipos una vez y, acto seguido, hacerse la pregunta que casi nadie se hace: si el resultado de la operación se modela con un status suelto y unos campos que pueden estar vacíos, o con una estructura donde los estados imposibles simplemente no se puedan escribir.
- Declarar los tres parámetros de tipo de
createAsyncThunky saber qué se pierde al omitirlos. - Construir un thunk preconfigurado del proyecto con
withTypesy los alias del store. - Tipar
pending,fulfilledyrejectedenextraReducers, incluido el payload opcional del rechazo. - Sustituir el trío de campos por una unión discriminada que impida representar estados imposibles.
Los tres parámetros de tipo y lo que cuesta omitirlos
createAsyncThunk acepta tres parámetros de tipo en este orden: el valor que la promesa resuelve, el argumento que recibe quien despacha, y un objeto de configuración con claves para el estado, el dispatch, el valor extra y el valor de rechazo. Los dos primeros suelen inferirse solos del payload creator y rara vez hay que escribirlos. El tercero no se infiere de nada, y ahí está el problema: si no lo declaras, thunkAPI.getState devuelve un valor de tipo desconocido y no puedes leer ninguna rama sin una aserción, thunkAPI.dispatch pierde las sobrecargas de tus middlewares, y rejectWithValue acepta cualquier cosa mientras el payload de la acción rechazada llega sin forma al reducer.
El detalle más sutil vive en el rechazo. Un thunk puede fallar de dos maneras distintas y Redux Toolkit las distingue: si el payload creator llama a rejectWithValue, la acción rejected lleva tu payload tipado; si en cambio lanza una excepción, no hay payload y el error aparece serializado en otro campo. Como el reducer no puede saber cuál de los dos caminos ocurrió, el payload de la acción rechazada es siempre opcional, aunque hayas declarado su tipo. Esa opcionalidad no es un defecto del tipado sino su lectura honesta de la realidad, y obliga a escribir en el reducer la ramificación que de verdad existe.
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState, AppDispatch } from "../../app/store";
interface Usuario { id: string; nombre: string }
interface ErrorApi { codigo: number; mensaje: string }
export const cargarUsuario = createAsyncThunk<
Usuario, // lo que resuelve
string, // el argumento
{ state: RootState; dispatch: AppDispatch; rejectValue: ErrorApi }
>("usuario/cargar", async (id, thunkAPI) => {
// getState conoce la forma del estado porque la declaramos arriba
const token = thunkAPI.getState().sesion.token;
const res = await fetch(`/api/usuarios/${id}`, { signal: thunkAPI.signal });
if (!res.ok) {
return thunkAPI.rejectWithValue({ codigo: res.status, mensaje: "fallo" });
}
return (await res.json()) as Usuario;
});
Repetir el objeto de configuración en cada thunk es la misma duplicación que los hooks resolvieron en la lección anterior, y tiene la misma solución. Redux Toolkit permite preconfigurar el creador con withTypes y exportarlo como el thunk del proyecto, de modo que cada thunk concreto solo declare lo suyo: qué resuelve y qué argumento recibe. La ganancia no es solo de teclas. Centralizar el tipo de rechazo obliga a decidir una forma de error común para toda la app, y esa decisión, tomada una vez y en un sitio visible, evita el mosaico habitual de errores donde unos thunks devuelven cadenas, otros objetos y otros nada.
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState, AppDispatch } from "./store";
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
state: RootState;
dispatch: AppDispatch;
rejectValue: ErrorApi;
}>();
// a partir de aqui cada thunk solo declara lo suyo
export const guardarPerfil = createAppAsyncThunk(
"perfil/guardar",
async (borrador: Borrador, { getState, rejectWithValue }) => {
if (!getState().sesion.token) return rejectWithValue({ codigo: 401, mensaje: "sin sesion" });
return await api.guardar(borrador);
},
);
Atender el ciclo con los tres casos tipados
En extraReducers, cada uno de los tres momentos llega con su tipo propio y merece un trato distinto. El caso pendiente no trae datos útiles salvo el argumento original en la metainformación. El caso resuelto trae el payload con el tipo declarado como valor de resolución, y ahí la inferencia funciona sin ayuda. El caso rechazado es el que exige atención, porque hay que ramificar entre el payload tipado y el error serializado. Escribir esa ramificación es incómodo la primera vez y después se agradece, porque distingue con claridad los fallos previstos del dominio de los imprevistos del programa.
flowchart TD A[createAsyncThunk con sus tres parametros] --> B[valor resuelto tipa fulfilled] A --> C[argumento tipa la invocacion] A --> D[configuracion tipa getState y rejectWithValue] B --> E[extraReducers con payload tipado] D --> E E --> F[union discriminada del recurso] F --> G[componentes que estrechan por el campo estado] style A fill:#fab387,color:#11111b style F fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b
Del trío de campos a la unión discriminada
El patrón canónico guarda tres campos sueltos: un status, los datos y el error. Es cómodo y es, en sentido estricto, un tipo producto: el compilador admite todas las combinaciones del producto de sus campos, incluidas las que tu aplicación jamás produce ni sabría interpretar. Estado resuelto con datos ausentes, estado cargando con error presente, estado fallido con datos de una carga anterior: combinaciones representables, y por tanto combinaciones que algún componente tendrá que comprobar defensivamente o, más probablemente, olvidará comprobar. La alternativa es modelar el recurso como una unión discriminada, un tipo suma donde cada variante lleva exactamente los campos que tienen sentido en ella y ninguno más.
type Recurso<T> =
| { estado: "inactivo" }
| { estado: "cargando" }
| { estado: "listo"; datos: T }
| { estado: "fallo"; error: ErrorApi };
const slice = createSlice({
name: "usuario",
initialState: { recurso: { estado: "inactivo" } } as { recurso: Recurso<Usuario> },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(cargarUsuario.pending, (estado) => {
estado.recurso = { estado: "cargando" };
})
.addCase(cargarUsuario.fulfilled, (estado, accion) => {
estado.recurso = { estado: "listo", datos: accion.payload };
})
.addCase(cargarUsuario.rejected, (estado, accion) => {
// payload solo existe si se uso rejectWithValue
const error = accion.payload ?? { codigo: 0, mensaje: accion.error.message ?? "error" };
estado.recurso = { estado: "fallo", error };
});
},
});
Trío de campos
Cómodo de escribir y de mutar, pero admite combinaciones imposibles que nadie comprueba hasta que fallan.
Unión discriminada
Cada variante lleva solo sus campos. El compilador obliga a estrechar antes de leer los datos.
Estrechamiento
Un condicional sobre el campo discriminante da acceso tipado al resto. El componente no puede leer datos inexistentes.
Una unión discriminada sigue siendo un objeto plano y por tanto serializable, así que no choca con la regla de Redux ni con las herramientas de desarrollo. Lo que sí cambia es cómo se modela la recarga con datos ya presentes: si al refrescar pasas de la variante lista a la de carga, pierdes los datos anteriores y la interfaz parpadea. La solución no es volver al trío suelto, sino admitir que esa aplicación tiene una variante más, la de datos presentes con refresco en curso, y declararla explícitamente. Descubrir variantes olvidadas al modelarlas es precisamente el beneficio del tipo suma: te obliga a nombrar los estados que ya vivían en tu interfaz sin que nadie los hubiera escrito.
Cuando eliges entre el trío de campos y la unión discriminada no estás eligiendo un estilo de código, estás eligiendo cuántos mundos posibles admite tu programa. Tres campos independientes describen el producto cartesiano de sus valores, y ese producto contiene siempre muchos más estados que los que tu dominio puede alcanzar; la diferencia entre unos y otros es un espacio de configuraciones absurdas que existe solo porque el tipo las permite, y que el equipo acaba habitando en forma de comprobaciones defensivas, campos vacíos que significan cosas distintas según el contexto y esos comentarios que dicen que si el estado es de carga entonces los datos no son fiables. Una unión discriminada elimina ese espacio por construcción: cada variante enumera lo que existe en ella, así que preguntar por unos datos que no existen deja de ser un descuido para pasar a ser un error de compilación. El principio general se enuncia en una frase, hacer irrepresentables los estados imposibles, pero su consecuencia práctica es más honda de lo que parece, porque cambia el destinatario de tu esfuerzo. Con el trío, el esfuerzo va a comprobar en ejecución, en cada punto de lectura, invariantes que nadie escribió; con la unión, va a modelar una vez, en el punto de definición, la forma real del proceso. Y hay un efecto epistemológico añadido: al obligarte a enumerar las variantes, el tipo te fuerza a mirar de frente estados que tu interfaz ya tenía y que nunca habías nombrado, como el refresco con datos previos o la carga cancelada. Modelar bien lo asíncrono no es tipar mejor lo que ya sabías; es descubrir, escribiendo el tipo, cuántas cosas distintas llamabas cargando.
- Toma un thunk existente sin configuración de tipos, intenta leer el estado con
getStatey anota qué error da antes de declarar nada. - Declara los tres parámetros de tipo, incluido el valor de rechazo, y comprueba qué información recuperas dentro del payload creator.
- Extrae la configuración a un thunk preconfigurado del proyecto con
withTypesy migra al menos dos thunks a esa base común. - En el reducer del caso rechazado, distingue el payload tipado del error serializado y comprueba qué ocurre cuando el thunk lanza en lugar de rechazar con valor.
- Reescribe el trío de campos como una unión discriminada y arregla los componentes hasta que ninguno lea datos sin estrechar antes.
- Añade la variante de refresco con datos previos, comprueba que el compilador te obliga a atenderla en todos los puntos de lectura y decide si tu interfaz la necesitaba desde el principio.