wandres.dev
TYPESCRIPT EN REDUX · tipar el store

PayloadAction: tipar el payload y cosechar la inferencia

Dentro de createSlice hay dos tipos que el compilador no puede adivinar solo: el del estado, que sale de initialState, y el del payload de cada accion, que sale de anotar el segundo parametro del case reducer con PayloadAction. Esta leccion muestra que esa unica anotacion viaja en dos direcciones, porque de ella se derivan tambien los action creators del slice y con ellos la verificacion de cada dispatch de la app. Recorre los cuatro parametros de PayloadAction, el caso sin payload, la forma prepare y su contrato con el reducer, y el efecto de Draft de Immer sobre los campos de solo lectura.

⏱ 17 min

Dentro de createSlice conviven dos tipos de naturaleza muy distinta. El del estado no hay que escribirlo dos veces: sale de initialState y llega solo a todos los reducers. El del payload sí hay que declararlo, porque una acción entra en el reducer desde fuera del slice y nada en el código dice qué trae dentro. Ese es el trabajo de PayloadAction, y lo interesante no es la anotación en sí sino su alcance: no sirve únicamente para tipar el parámetro donde la escribes, sino que es el punto desde el cual Redux Toolkit deduce la firma del action creator correspondiente. Anotas una vez, en el lugar donde se define el significado de la acción, y esa anotación viaja hasta cada dispatch de la aplicación para comprobar que quien despacha manda exactamente lo que el reducer espera recibir.

🎯 Al terminar esta lección sabrás
  • Distinguir qué tipo aporta initialState y qué tipo aporta PayloadAction dentro de un slice.
  • Anotar el payload de cada case reducer y aprovechar los action creators inferidos.
  • Manejar acciones sin payload y los parámetros de tipo, meta y error de PayloadAction.
  • Respetar el contrato entre prepare y su reducer, y entender el borrador de Immer como Draft.

Dos tipos con orígenes distintos

El estado de un slice se infiere de initialState, y por eso ese valor merece más cuidado del que suele recibir. Un objeto literal escrito directamente en la llamada produce tipos ensanchados o demasiado estrechos según el campo: un array vacío se infiere como array de nada, un null se infiere como null a secas, y una cadena literal como idle se ensancha a string perdiendo la unión que querías. La forma robusta es declarar una interfaz para el estado, construir la constante inicial anotada con ella y pasarla al slice. A partir de ahí, todos los reducers ven el estado con el tipo correcto sin una sola anotación adicional, y la quinta lección de este nivel estudia con detalle qué ocurre cuando esta pieza se descuida.

La acción es el caso opuesto. Su payload no está en ninguna parte del slice hasta que lo declaras, así que el segundo parámetro del case reducer se anota con PayloadAction y el tipo del payload como argumento. Esa anotación no es defensiva sino generativa: Redux Toolkit la lee para construir el action creator de esa clave, que pasa a exigir un argumento de ese mismo tipo. Por eso conviene pensar en PayloadAction no como un tipo de entrada sino como la declaración del contrato de la acción, escrita en el único sitio donde ese contrato tiene sentido, que es junto a la lógica que lo consume.

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface Todo { id: string; texto: string; hecho: boolean }

interface EstadoTodos {
  lista: Todo[];
  filtro: "todos" | "pendientes" | "hechos";
  seleccionado: string | null;
}

// anotar la constante evita el ensanchamiento de filtro y de seleccionado
const inicial: EstadoTodos = { lista: [], filtro: "todos", seleccionado: null };

const todosSlice = createSlice({
  name: "todos",
  initialState: inicial,
  reducers: {
    anadido: (estado, accion: PayloadAction<Todo>) => {
      estado.lista.push(accion.payload);
    },
    filtrado: (estado, accion: PayloadAction<EstadoTodos["filtro"]>) => {
      estado.filtro = accion.payload;
    },
    limpiado: (estado) => {
      estado.lista = [];
    },
  },
});
flowchart LR
A[initialState anotado] --> B[tipo del estado en cada reducer]
C[PayloadAction del case reducer] --> D[firma del action creator]
D --> E[cada dispatch verificado en los componentes]
D --> F[addCase en extraReducers de otros slices]
B --> G[Draft de Immer dentro del reducer]
style C fill:#fab387,color:#11111b
style E fill:#a6e3a1,color:#11111b

La inferencia que devuelve la anotación

Lo que gana el proyecto con esa única anotación es una cadena de verificaciones que no costó nada montar. El action creator generado exige el payload correcto, de modo que despachar con un objeto incompleto o con un campo sobrante falla en el editor, en el componente, señalando la propiedad exacta. La cadena continúa hacia otros slices: cuando un tercero atiende esa acción con addCase, el parámetro de la acción llega ya tipado sin repetir nada. Y continúa hacia los tests, donde construir la acción con su creator garantiza que la prueba no se desincronice del reducer cuando el payload cambie de forma.

import { createSlice } from "@reduxjs/toolkit";
import { anadido } from "../todos/todosSlice";

const estadisticas = createSlice({
  name: "estadisticas",
  initialState: { creados: 0, ultimoTexto: "" },
  reducers: {},
  extraReducers: (builder) => {
    // accion.payload llega tipado como Todo sin declararlo aqui
    builder.addCase(anadido, (estado, accion) => {
      estado.creados += 1;
      estado.ultimoTexto = accion.payload.texto;
    });
  },
});

Fíjate en la dirección del flujo: el slice de estadísticas no sabe nada del payload y tampoco lo declara, se limita a importar el creator y dejar que su tipo viaje con él. Si mañana la acción cambia de forma, este reducer deja de compilar aunque viva en otra carpeta y lo mantenga otra persona. Esa es la diferencia práctica entre un tipo que acompaña al valor y una convención documentada: el primero recorre las fronteras de módulo por su cuenta, la segunda se queda en el fichero donde alguien la escribió.

🔷

Payload declarado

PayloadAction con el tipo del payload. Es la forma común y la que genera un creator que exige un argumento.

🔶

Sin payload

Omitir el parámetro de la acción produce un creator sin argumentos. Es más honesto que declarar un payload vacío.

🔵

Con meta y error

PayloadAction admite además el tipo de acción, el de meta y el de error, útiles para trazas y para acciones de ciclo de vida.

💡
Un payload es un mensaje, no un volcado del estado

La disciplina de tipar payloads tiene un efecto secundario valioso: obliga a nombrar qué viaja en cada acción y hace visible cuando ese contenido crece de más. Si el tipo del payload empieza a parecerse a la porción de estado que el reducer va a escribir, es señal de que la acción ha dejado de describir un hecho del dominio y ha pasado a describir una mutación, que es justo lo que el flujo unidireccional quería evitar. Un payload sano suele ser pequeño y con nombre de suceso, como el identificador de lo que se alternó o el texto de lo que se añadió. Cuando notes que estás tipando el payload como el estado entero, revisa si la acción está en el nivel de abstracción correcto.

Sin payload, con prepare y bajo el borrador de Immer

Hay tres bordes que conviene dominar. El primero es la acción sin datos: basta con omitir el segundo parámetro y Redux Toolkit generará un creator sin argumentos. Declarar un payload de tipo vacío para esos casos solo consigue obligar a quien despacha a escribir un argumento inútil. El segundo es la forma con prepare, que existe para hacer trabajo antes de construir el payload, típicamente generar un identificador o una marca de tiempo. Ahí aparece un contrato de tipos que hay que respetar: lo que prepare devuelve como payload debe coincidir con lo que su reducer declara, y si divergen el error aparece en la definición del slice, no en el punto de uso. Es un buen error, porque señala una incoherencia interna antes de que llegue a nadie.

import { createSlice, nanoid, type PayloadAction } from "@reduxjs/toolkit";

const slice = createSlice({
  name: "todos",
  initialState: inicial,
  reducers: {
    anadido: {
      // prepare recibe lo que pasa quien despacha y arma el payload
      prepare: (texto: string) => ({
        payload: { id: nanoid(), texto, hecho: false },
        meta: { creadoEn: Date.now() },
      }),
      // el tipo del payload debe coincidir con lo que prepare devuelve
      reducer: (estado, accion: PayloadAction<Todo, string, { creadoEn: number }>) => {
        estado.lista.push(accion.payload);
      },
    },
  },
});

El tercer borde es el estado, no la acción. Dentro de un case reducer, el estado no llega con su tipo declarado sino envuelto en el borrador de Immer, que lo transforma en una versión mutable de sí mismo. En la práctica es transparente hasta que el estado contiene campos de solo lectura o tuplas fijas: entonces el borrador y la declaración discrepan y aparecen errores desconcertantes al asignar. La lectura correcta de esos errores es que el tipo del estado y el tipo del borrador no son el mismo, y que casi siempre la solución está en el estado, no en una aserción sobre el borrador.

📝
Los tipos utilitarios del slice cuando necesitas nombrar sus acciones

A veces hace falta referirse a las acciones de un slice desde fuera, por ejemplo para tipar un middleware que las escucha o un listener que reacciona a un grupo. En lugar de reescribir a mano el tipo de la acción, conviene derivarlo del propio creator: cada creator generado expone el tipo de la acción que produce, y también los tipos utilitarios de Redux Toolkit permiten componerlos. La regla es la misma que abrió el nivel: si el creator ya existe, su tipo se deduce de él y no se declara aparte, porque un tipo de acción escrito a mano en un middleware es exactamente la clase de duplicado que deja de compilar cuando alguien cambia el payload y no se entera nadie.

Tipar el payload es escribir el contrato donde vive su significado

Una acción de Redux es un mensaje entre dos partes que no se conocen: quien despacha no sabe qué reducers la atenderán y el reducer no sabe quién la envió. Esa ignorancia mutua es la virtud central del flujo unidireccional, porque permite que el emisor y el receptor evolucionen por separado, pero deja abierta la pregunta de quién custodia el significado del mensaje. Sin tipos, la respuesta es nadie: el significado vive en la memoria del equipo y en la coincidencia afortunada entre lo que uno manda y lo que otro lee. PayloadAction responde esa pregunta colocando el contrato en el lugar exacto donde reside el significado, que es el reducer, porque es ahí donde se decide qué efecto tiene la acción sobre el mundo. Y una vez el contrato vive ahí, la dirección de la comprobación se invierte respecto a lo que sugiere la intuición: no es el reducer quien se protege de payloads mal formados, es el punto de despacho el que queda vinculado a una definición que no controla. Esa inversión es la que convierte el tipado de acciones en algo cualitativamente distinto de una validación. Una validación comprueba datos en ejecución, en un punto, y falla tarde; un contrato de tipos propaga una definición hacia todos sus usos y falla en el sitio donde se escribió el error. Por eso una sola anotación bien puesta rinde tanto: no añade una comprobación, distribuye una definición. Y por eso conviene tratar cada PayloadAction con el mismo respeto que una firma pública, porque eso es lo que es, aunque su sintaxis parezca la de un detalle interno del reducer.

⚔️ Convierte tus acciones en contratos verificados
  1. Toma un slice existente y anota el payload de todos sus case reducers. Anota cuántos puntos de despacho dejan de compilar y por qué.
  2. Sustituye un payload que hoy es un objeto grande por uno mínimo con nombre de suceso, y traslada al reducer la lógica que sobre.
  3. Añade una acción sin datos omitiendo el parámetro de la acción y comprueba la firma del creator generado.
  4. Escribe un reducer con prepare que genere identificador y marca de tiempo, rompe a propósito la coincidencia de tipos entre prepare y su reducer y lee dónde aparece el error.
  5. Atiende una de esas acciones desde otro slice con addCase y confirma que el payload llega tipado sin volver a declararlo.
  6. Añade un campo de solo lectura al estado, provoca el choque con el borrador de Immer y decide si la solución correcta es cambiar el estado o el reducer.