createSlice: reducers, acciones e Immer en un solo lugar
createSlice es el corazón de Redux Toolkit: recibe un nombre, un estado inicial y un objeto de reducers, y devuelve el reducer, los action creators y los tipos de acción generados y coherentes. Esta lección desmonta su anatomía, explica cómo Immer permite escribir mutaciones que se compilan a actualizaciones inmutables mediante un borrador con proxy y compartición estructural, y enuncia la regla que nunca se rompe: mutar el borrador o devolver estado nuevo, jamás ambas cosas. Cubre la forma prepare para dar forma al payload, extraReducers para responder a acciones de otros slices y thunks, current para inspeccionar borradores, y los selectores colocados junto al slice en RTK moderno.
Si configureStore es el esqueleto de una app con Redux Toolkit, createSlice es el músculo: la unidad donde declaras, en un solo bloque legible, qué forma tiene una porción del estado y cómo puede cambiar. Su golpe de gracia es doble. Por un lado, colapsa las tres piezas que el Redux clásico obligaba a mantener sincronizadas —tipos de acción, action creators y reducer— en una única declaración de la que RTK deriva todo lo demás. Por otro, integra Immer, y con él una ilusión productiva: dentro de un reducer escribes lo que parece una mutación directa, estado.items.push(x), y obtienes una actualización perfectamente inmutable. Entender por qué esa ilusión es segura, y dónde deja de serlo, es lo que separa usar createSlice de dominarlo.
- Leer la anatomía de un slice y saber qué genera RTK a partir de
name,initialStateyreducers. - Explicar cómo Immer traduce mutaciones sobre un borrador a estado inmutable con compartición estructural.
- Aplicar la regla de oro de Immer: mutar el borrador o devolver estado nuevo, nunca ambas cosas.
- Usar la forma
prepare,extraReducerscon el builder y los selectores del slice.
Un slice es acciones y reducer a la vez
createSlice recibe un objeto de configuración y devuelve otro con tres piezas clave: slice.reducer, la función pura que registras en el store; slice.actions, un objeto con un action creator por cada clave de reducers; y slice.caseReducers, útil para tests. Los tipos de acción se derivan solos: la clave incrementar bajo un slice llamado contador produce el tipo contador/incrementar, sin que escribas esa cadena. Cada reducer recibe el estado y la acción; si necesitas el payload tipado, anotas el segundo argumento como PayloadAction<T>.
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface Todo { id: string; texto: string; hecho: boolean }
const todosSlice = createSlice({
name: "todos",
initialState: [] as Todo[],
reducers: {
anadido: (estado, accion: PayloadAction<Todo>) => {
estado.push(accion.payload); // parece mutacion; Immer la hace inmutable
},
alternado: (estado, accion: PayloadAction<string>) => {
const t = estado.find((x) => x.id === accion.payload);
if (t) t.hecho = !t.hecho;
},
},
});
export const { anadido, alternado } = todosSlice.actions;
export default todosSlice.reducer;
slice.reducer
La función pura que registras en configureStore. Combina los case reducers y aplica Immer por debajo.
slice.actions
Un action creator por cada clave de reducers, con su tipo derivado. Los importas y despachas sin escribir cadenas de tipo.
slice.selectors
Selectores atados a la porción local del estado, agnósticos de dónde montes el slice en el árbol.
Immer: mutar el borrador, no el estado
La magia no es magia: es un proxy. Cuando despachas una acción, Immer no te entrega el estado real sino un borrador, un proxy que registra cada escritura que haces sobre él. Al terminar el reducer, Immer usa ese registro para producir un estado nuevo aplicando solo los cambios anotados y reutilizando por referencia todo lo que no tocaste. Eso es compartición estructural: si cambias un elemento de un array de mil, los otros novecientos noventa y nueve se comparten con el estado anterior, no se copian. Obtienes la ergonomía de la mutación y la semántica de la inmutabilidad a la vez, sin spread anidado y sin copias innecesarias.
flowchart LR A[dispatch de una accion] --> B[Immer crea un borrador] B --> C[el reducer muta el borrador] C --> D[Immer calcula los parches] D --> E[estado nuevo inmutable] E --> F[lo no tocado se comparte por referencia] style B fill:#fab387,color:#11111b style E fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b
La regla que nunca debes romper nace de cómo funciona ese proxy: dentro de un reducer, o mutas el borrador y no devuelves nada, o devuelves un estado nuevo sin tocar el borrador. Hacer las dos cosas a la vez confunde a Immer, que no sabe si respetar tus parches o tu valor devuelto, y lanza un error. Devolver estado nuevo es legítimo y a veces más claro —reiniciar a un valor inicial, por ejemplo—, pero mezclarlo con mutaciones no lo es.
reducers: {
// BIEN: muta el borrador y no devuelve nada
anadir: (estado, accion: PayloadAction<Todo>) => {
estado.items.push(accion.payload);
},
// BIEN: devuelve estado nuevo sin tocar el borrador
reiniciar: () => inicial,
// MAL: mutar Y devolver a la vez rompe Immer
// roto: (estado) => { estado.n += 1; return estado; },
}
El proxy de Immer solo es válido durante la ejecución del reducer. No guardes el borrador en una variable externa, no lo devuelvas por una promesa, no lo leas después: fuera de su ámbito el proxy queda revocado y cualquier acceso falla. Si necesitas ver el contenido real de un borrador para depurar, no lo imprimas directamente —verías el proxy—; envuélvelo en current(estado), que te da una instantánea inmutable y legible del borrador en ese momento.
La forma prepare, extraReducers y selectores
A veces el action creator debe hacer trabajo antes de construir el payload: generar un id, poner una marca de tiempo, normalizar argumentos. Para eso está la forma de objeto con reducer y prepare: prepare recibe los argumentos de quien despacha y devuelve el objeto de acción, mientras reducer recibe ya el payload preparado. Y cuando un slice debe reaccionar a acciones que no son suyas —las de otro slice o los estados de un createAsyncThunk—, se usa extraReducers con el builder y su método addCase.
import { createSlice, nanoid, type PayloadAction } from "@reduxjs/toolkit";
const todos = createSlice({
name: "todos",
initialState: { lista: [] as Todo[] },
reducers: {
anadido: {
reducer: (estado, accion: PayloadAction<Todo>) => {
estado.lista.push(accion.payload);
},
prepare: (texto: string) => ({
payload: { id: nanoid(), texto, hecho: false },
}),
},
},
// responde a acciones externas, como un thunk de otro modulo
extraReducers: (builder) => {
builder.addCase(sincronizar.fulfilled, (estado, accion) => {
estado.lista = accion.payload;
});
},
// selectores colocados junto al slice, con el estado ya recortado a esta porcion
selectors: {
seleccionarPendientes: (estado) => estado.lista.filter((t) => !t.hecho),
},
});
export const { anadido } = todos.actions;
export const { seleccionarPendientes } = todos.selectors;
Junto a addCase, el builder de extraReducers ofrece addMatcher, que atiende cualquier acción cuyo predicado se cumpla —ideal para reaccionar de golpe a todos los rejected de tus thunks o a un grupo de acciones con prefijo común—, y addDefaultCase para lo que no encaje en ningún caso anterior. El orden es fijo: primero los addCase, luego los addMatcher en orden de registro, y al final el addDefaultCase. Es la vía para lógica transversal sin repetir el mismo reducer en cada slice.
Definir selectors dentro del slice tiene una ventaja de mantenimiento: la función recibe el estado ya recortado a la porción del slice, no la RootState entera, así que no codificas dónde está montado el slice en el árbol. Si mañana mueves el slice de state.todos a state.entidades.todos, los selectores del slice siguen funcionando sin tocarse, mientras que un selector escrito a mano contra la raíz habría que corregirlo. Para derivaciones caras, combínalos con createSelector de Reselect y ganarás además memoización.
Lo que Immer resuelve dentro de createSlice es una tensión vieja entre dos bienes que parecían enemigos: la ergonomía de mutar y la seguridad de no mutar. Escribir estado.items.push(x) es directo, legible, natural para cualquier programador; escribir la versión inmutable a mano, { ...estado, items: [...estado.items, x] }, es correcto pero ruidoso, y en estructuras profundas se vuelve un laberinto de spreads donde es fácil compartir por accidente una referencia que debías copiar y corromper el estado anterior. Immer disuelve la falsa disyuntiva convirtiendo la mutación en pura sintaxis: tú escribes como si mutaras, pero lo que ocurre es que un proxy anota tus intenciones y fabrica con ellas un valor nuevo, dejando intacto el original y compartiendo por referencia todo lo que no cambió. Esa compartición estructural no es un detalle de rendimiento menor; es lo que permite que las comparaciones por referencia —el Object.is del que dependen React y los selectores memoizados— sigan siendo baratas y correctas, porque una rama del árbol que no cambió conserva su identidad. Comprender esto reordena tu forma de pensar los reducers: dejas de ver la inmutabilidad como una carga que Immer te ahorra y empiezas a verla como una propiedad semántica que el sistema garantiza mientras tú te expresas en la sintaxis más cómoda. Y la única regla que hay que respetar —mutar el borrador o devolver estado nuevo, nunca las dos— deja de ser arbitraria: es la consecuencia directa de que el proxy no puede reconciliar a la vez tus parches y un valor que devuelves, porque son dos descripciones rivales del mismo estado siguiente.
- Escribe un slice con estado anidado —una lista de objetos con subcampos— y actualiza un campo profundo mutando el borrador; luego escribe la versión inmutable a mano con spread y compara legibilidad.
- Provoca a propósito el error de Immer mutando y devolviendo a la vez; lee el mensaje y corrígelo de las dos formas posibles.
- Añade un reducer con forma
prepareque genere un id connanoidy una marca de tiempo, y comprueba que quien despacha solo pasa el texto. - Usa
current(estado)dentro de un reducer para imprimir el borrador de forma legible y observa la diferencia con imprimir el proxy directamente. - Define un selector dentro del slice, mueve el slice a otra rama del árbol en
configureStorey confirma que el selector sigue funcionando sin cambios.