El almacén de CicloUrbano existe, arranca y se ve en DevTools, pero está vacío: tres slices provisionales con reducers: {} que no aceptan ninguna acción. Esta lección lo llena. Aquí es donde se hace el trabajo de verdad de Redux, que no consiste en configurar nada sino en modelar el estado y sus transiciones: decidir qué forma tiene cada dominio, qué sucesos puede recibir y cómo cambia con cada uno. Vas a ver la anatomía de una acción y cómo nombrarla, createSlice a fondo y todo lo que genera por ti, la inmutabilidad aparente de Immer —el mecanismo que más sorprende a quien viene del Redux clásico— y la única regla que no se puede romper con él, la preparación de acciones con prepare para mantener los reductores puros, los tres slices completos de CicloUrbano, los selectores como frontera entre los componentes y la forma del estado, la normalización de entidades por identificador y la lógica asíncrona con createAsyncThunk. Al terminar, el almacén tendrá todo el comportamiento del cliente de CicloUrbano modelado y probado. Conectarlo a los componentes sigue siendo cosa de 07-05: aquí no aparece ni un useSelector.
Contenido
- Anatomía de una acción y cómo nombrarla
createSlicea fondo: qué recibe y qué genera- La inmutabilidad aparente de Immer
- La regla que no se puede romper
- Reductores con preparación:
prepare sliceCatalogo: filtro, búsqueda y ordensliceSesion: usuario y accesosliceReservas: crear, confirmar y cancelar- Selectores: la frontera con la forma del estado
- Normalizar el estado por identificador
createEntityAdapteren breve- Lógica asíncrona con
createAsyncThunk extraReducerspara reaccionar a otros slices- Probar reductores y selectores
- Anatomía de una acción y cómo nombrarla
Una acción de Redux es un objeto plano con una forma fijada por convención:
{
type: 'reservas/reservaConfirmada', // obligatorio: qué ha ocurrido
payload: 'res-01' // opcional: los datos del suceso
}| Campo | Obligatorio | Qué contiene |
|---|---|---|
type |
Sí | Una cadena que identifica el suceso. Debe ser única en toda la aplicación |
payload |
No | Los datos necesarios para aplicar el cambio. Un valor, un objeto, lo que haga falta |
meta |
No | Información adicional que no es parte del cambio (por ejemplo, el identificador de la petición) |
error |
No | true si la acción representa un fallo |
Nombrar bien es la mitad del trabajo. La convención de RTK es dominio/sucesoOcurrido:
| ✅ Bien | ❌ Mal | Por qué |
|---|---|---|
reservas/reservaConfirmada |
CONFIRMAR_RESERVA |
La acción describe lo que ha pasado, no una orden. En pasado, como en 05-05 |
catalogo/tipoCambiado |
SET_TIPO |
SET_X describe una asignación, no un suceso del dominio |
sesion/sesionCerrada |
LOGOUT |
El prefijo del dominio evita colisiones entre slices |
reservas/reservaCancelada |
updateReservaStatus |
Un nombre genérico obliga a leer el reductor para saber qué hace |
El prefijo del dominio no es decorativo: hace que el historial de DevTools se lea como una crónica —sesion/sesionIniciada, catalogo/tipoCambiado, reservas/reservaCreada— y que el type sea único sin esfuerzo. Con createSlice no tienes que escribirlo: se genera concatenando el name del slice y el nombre del reductor.
Esta es la misma disciplina de 05-05 —acciones en pasado que describen sucesos— con dos ajustes obligatorios de Redux: el campo se llama type y no tipo, y los datos van en payload en vez de en campos sueltos. Son APIs de la biblioteca y, como el resto del curso, no se traducen.
createSlice a fondo: qué recibe y qué genera
createSlice a fondo: qué recibe y qué generaUn slice es «una porción del estado con todo lo que la gobierna»: su estado inicial, sus transiciones y, por convención, sus selectores. createSlice recibe un objeto de configuración:
| Clave | Qué es |
|---|---|
name |
Nombre del dominio. Se usa como prefijo de todos los type |
initialState |
El estado de partida de esta porción |
reducers |
Un objeto { nombreDelSuceso: (estado, accion) => … }. Cada clave genera un creador de acción |
extraReducers |
Respuestas a acciones definidas fuera de este slice (apartados 12 y 13) |
selectors |
Selectores declarados junto al slice (apartado 9) |
Y devuelve un objeto con tres cosas:
const sliceCatalogo = createSlice({
name: 'catalogo',
initialState: { tipo: 'todos' },
reducers: {
tipoCambiado(estado, accion) {
estado.tipo = accion.payload;
}
}
});
sliceCatalogo.name; // 'catalogo'
sliceCatalogo.reducer; // la función reductora, para configureStore
sliceCatalogo.actions; // { tipoCambiado: creadorDeAccion }
sliceCatalogo.actions.tipoCambiado('electrica');
// → { type: 'catalogo/tipoCambiado', payload: 'electrica' }Vale la pena detenerse en lo que acaba de ocurrir, porque es la mayor parte del ahorro respecto del Redux clásico. De una definición de nueve líneas han salido:
- La constante de tipo
'catalogo/tipoCambiado', que no has escrito y por tanto no puedes escribir mal. - El creador de acción
tipoCambiado, que empaqueta su argumento comopayload. - El caso del reductor que responde a ese tipo.
- El
defaultque devuelve el estado intacto cuando la acción no es suya.
En el Redux clásico eso eran tres ficheros y unas treinta líneas.
Convención de exportación del slice —la que se usa en todo CicloUrbano y encaja con la del proyecto («export default para componentes y exportación nombrada para lo demás»), con una excepción práctica:
// Los creadores de acción, con nombre
export const { tipoCambiado, terminoCambiado, ordenCambiado } = sliceCatalogo.actions;
// El reductor, por defecto: es lo único que importa configureStore
export default sliceCatalogo.reducer;El reductor va por defecto porque cada fichero de slice tiene exactamente uno y así el import reductorCatalogo from '…/sliceCatalogo.js' del almacén queda limpio.
- La inmutabilidad aparente de Immer
Mira otra vez el reductor del apartado anterior:
Esto contradice frontalmente el principio 3 de 07-03 y todo lo que aprendiste en 05-01 y 05-05. Y sin embargo es correcto, es la forma recomendada y no muta nada.
La explicación es Immer, una biblioteca que RTK incluye y aplica automáticamente dentro de createSlice. Lo que ocurre por debajo:
- Antes de llamar a tu reductor, Immer envuelve el estado actual en un borrador (draft), un objeto proxy que parece el estado real.
- Tu código escribe sobre el borrador:
estado.tipo = 'electrica',estado.ids.push('res-02'),delete estado.entidades['res-01']. - El proxy no aplica esos cambios: los anota.
- Al terminar el reductor, Immer construye un objeto nuevo aplicando las anotaciones, copiando solo las ramas afectadas y reutilizando las referencias de todo lo que no cambió.
flowchart LR
A["Estado actual<br/>(inmutable)"] --> B["Immer crea<br/>un borrador (proxy)"]
B --> C["Tu reductor 'muta'<br/>el borrador"]
C --> D["Immer anota<br/>los cambios"]
D --> E["Estado nuevo<br/>copiando solo<br/>las ramas tocadas"]
A -. "las ramas intactas<br/>comparten referencia" .-> E
Compara el mismo cambio escrito de las dos formas:
// Sin Immer: inmutabilidad a mano, tres niveles de propagación
function reductor(estado, accion) {
return {
...estado,
entidades: {
...estado.entidades,
[accion.payload]: {
...estado.entidades[accion.payload],
estado: 'confirmada'
}
}
};
}
// Con Immer, dentro de createSlice
reservaConfirmada(estado, accion) {
estado.entidades[accion.payload].estado = 'confirmada';
}Cinco líneas de propagación de ... sustituidas por una que dice exactamente lo que hace. Y la última fila del diagrama importa para el rendimiento: las ramas que no se tocan conservan su referencia, así que un componente que lea estado.catalogo no verá ningún cambio cuando se confirme una reserva. Es lo que hace que la comparación por identidad de useSelector (07-05) funcione tan bien.
Dos precisiones para que el modelo mental sea correcto:
- Immer solo actúa dentro de
createSliceycreateReducer. Un reductor escrito a mano y pasado directamente aconfigureStoreno tiene borrador: ahí mutar es mutar, y saltará el aviso deimmutableCheckque viste en 07-03. - El borrador solo existe durante la ejecución del reductor. No puedes guardártelo, ni pasarlo a un
setTimeout, ni devolverlo desde una promesa.
- La regla que no se puede romper
Immer tiene exactamente una regla, y romperla produce fallos difíciles de entender:
O modificas el borrador, o devuelves un estado nuevo. Nunca las dos cosas en el mismo reductor.
// ✅ CORRECTO: modificar el borrador y no devolver nada
reservaConfirmada(estado, accion) {
estado.entidades[accion.payload].estado = 'confirmada';
}
// ✅ CORRECTO: devolver un estado nuevo y no tocar el borrador
catalogoReiniciado() {
return ESTADO_INICIAL_CATALOGO;
}
// ❌ INCORRECTO: las dos cosas
reservaConfirmada(estado, accion) {
estado.entidades[accion.payload].estado = 'confirmada';
return { ...estado, ultimaAccion: 'confirmar' }; // Immer no sabe qué hacer con esto
}El caso del reinicio merece atención porque es el que más se falla:
// ❌ NO funciona: reasignar el parámetro no cambia nada fuera
catalogoReiniciado(estado) {
estado = ESTADO_INICIAL_CATALOGO; // solo cambia la variable local
}
// ✅ Sí funciona
catalogoReiniciado() {
return ESTADO_INICIAL_CATALOGO;
}Reasignar estado es JavaScript básico: cambias la variable local, no el objeto al que apuntaba. Immer no puede detectarlo.
Y un tercer caso, el más sutil de todos:
// ❌ Peligroso: se guarda una referencia al borrador
let ultimoEstado;
reservaCreada(estado, accion) {
estado.entidades[accion.payload.id] = accion.payload;
ultimoEstado = estado; // el borrador se revoca al terminar; usarlo después lanza
}Fuera del reductor, ultimoEstado apunta a un proxy revocado y cualquier acceso lanza Cannot perform 'get' on a proxy that has been revoked. Si necesitas el estado resultante, léelo del almacén.
Operaciones seguras sobre el borrador, las que usarás a diario:
| Operación | Ejemplo |
|---|---|
| Asignar una propiedad | estado.tipo = accion.payload |
| Añadir a un array | estado.ids.push(nuevoId) |
| Quitar de un array | estado.ids = estado.ids.filter((id) => id !== accion.payload) |
| Añadir una clave a un objeto | estado.entidades[id] = reserva |
| Borrar una clave | delete estado.entidades[id] |
| Modificar un elemento anidado | estado.entidades[id].estado = 'confirmada' |
- Reductores con preparación:
prepare
prepareEl principio 3 dice que un reductor es puro: sin identificadores aleatorios, sin new Date(), sin nada no determinista. En 05-05 resolviste esto generando la reserva en el manejador y pasándola ya construida al reductor. Funciona, pero desplaza el problema: cada sitio que cree una reserva tiene que acordarse de construirla igual.
RTK ofrece una solución mejor: la notación de preparación. En vez de una función, un reductor puede ser un objeto con reducer y prepare.
reservaCreada: {
// prepare construye la acción: aquí SÍ puede haber efectos no deterministas
prepare(bicicletaId, usuarioId, fechaInicio, horas) {
return {
payload: {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId,
usuario: usuarioId,
fechaInicio,
horas,
estado: 'activa',
creadaEn: new Date().toISOString()
}
};
},
// reducer sigue siendo puro: solo coloca lo que le llega
reducer(estado, accion) {
const reserva = accion.payload;
estado.entidades[reserva.id] = reserva;
estado.ids.push(reserva.id);
}
}// Quien lo usa no sabe nada de identificadores ni de fechas
reservaCreada('bici-002', 'usr-01', '2026-05-04T09:00', 2);
// → { type: 'reservas/reservaCreada',
// payload: { id: 'res-3f7a1c9e', bicicletaId: 'bici-002', usuario: 'usr-01',
// fechaInicio: '2026-05-04T09:00', horas: 2,
// estado: 'activa', creadaEn: '2026-05-04T08:52:10.114Z' } }Qué se gana:
- El reductor sigue siendo puro, así que se puede probar con una entrada fija y sigue funcionando el viaje en el tiempo: al repetir el historial, la acción ya tiene su identificador y su fecha grabados, y el resultado es idéntico.
- La construcción está en un único sitio. El formulario, un botón de «repetir reserva» y una prueba crean reservas exactamente igual.
- La firma es cómoda.
reservaCreada('bici-002', 'usr-01', …)en vez de obligar a cada llamador a montar el objeto entero. - Las fechas se guardan como cadenas ISO, respetando la serializabilidad de 07-03.
prepare puede devolver también meta y error además de payload, aunque en CicloUrbano no hará falta.
sliceCatalogo: filtro, búsqueda y orden
sliceCatalogo: filtro, búsqueda y ordenEmpezamos por el más sencillo, que además prepara el terreno para los selectores derivados.
// src/funcionalidades/catalogo/sliceCatalogo.js
import { createSlice } from '@reduxjs/toolkit';
const ESTADO_INICIAL = {
tipo: 'todos', // 'todos' | 'urbana' | 'electrica' | 'carga'
termino: '', // texto del buscador, ya sin retraso
orden: 'modelo', // 'modelo' | 'precio'
bicicletas: [], // catálogo cargado desde la API (temporal: ver nota)
estadoCarga: 'inactivo', // 'inactivo' | 'cargando' | 'correcto' | 'error'
error: null // mensaje del último fallo de carga
};
const sliceCatalogo = createSlice({
name: 'catalogo',
initialState: ESTADO_INICIAL,
reducers: {
tipoCambiado(estado, accion) {
estado.tipo = accion.payload;
},
terminoCambiado(estado, accion) {
estado.termino = accion.payload;
},
ordenCambiado(estado, accion) {
estado.orden = accion.payload;
},
filtrosReiniciados(estado) {
// Se reinician los criterios, NO las bicicletas ya cargadas
estado.tipo = 'todos';
estado.termino = '';
estado.orden = 'modelo';
}
},
selectors: {
seleccionarBicicletas: (estado) => estado.bicicletas,
seleccionarTipo: (estado) => estado.tipo,
seleccionarTermino: (estado) => estado.termino,
seleccionarOrden: (estado) => estado.orden,
seleccionarEstadoCargaCatalogo: (estado) => estado.estadoCarga,
seleccionarErrorCatalogo: (estado) => estado.error
}
});
export const { tipoCambiado, terminoCambiado, ordenCambiado, filtrosReiniciados } =
sliceCatalogo.actions;
export const {
seleccionarBicicletas, seleccionarTipo, seleccionarTermino,
seleccionarOrden, seleccionarEstadoCargaCatalogo, seleccionarErrorCatalogo
} = sliceCatalogo.selectors;
export default sliceCatalogo.reducer;Dos comentarios sobre decisiones concretas:
filtrosReiniciadosno devuelveESTADO_INICIAL. Si lo hiciera, borraría también las bicicletas cargadas y el estado de carga. Modifica los tres campos que le corresponden y deja el resto intacto: es la regla del apartado 4 aplicada con criterio.bicicletasestá aquí de forma provisional y con fecha de caducidad. Según la taxonomía de 07-01, es estado del servidor y no debería vivir en un almacén de cliente. Se pone aquí para poder enseñarcreateAsyncThunkcon un caso real, y en 07-06 saldrá del slice hacia una caché de consultas. Está bien que lo veas hacer y deshacer: es exactamente lo que pasa en un proyecto real cuando se introduce una biblioteca de estado de servidor.
Y una advertencia que se resuelve del todo en 07-05: que tipo exista en el slice no significa que el filtro deje de vivir en la URL. El filtro del catálogo sigue siendo estado de URL (06-02, 07-01) y no puede tener dos fuentes de verdad. Cómo se concilian las dos cosas es una decisión que se toma al conectar los componentes.
sliceSesion: usuario y acceso
sliceSesion: usuario y acceso// src/funcionalidades/sesion/sliceSesion.js
import { createSlice } from '@reduxjs/toolkit';
import { usuarios } from '../../datos/dominio.js';
const ESTADO_INICIAL = {
usuario: null, // { id, nombre, email, rol } o null si no hay sesión
cargando: true, // true mientras se comprueba si hay una sesión guardada
error: null // mensaje si el acceso falla
};
const sliceSesion = createSlice({
name: 'sesion',
initialState: ESTADO_INICIAL,
reducers: {
sesionIniciada(estado, accion) {
estado.usuario = accion.payload; // el usuario completo, ya resuelto
estado.cargando = false;
estado.error = null;
},
sesionCerrada(estado) {
estado.usuario = null;
estado.cargando = false;
estado.error = null;
},
accesoFallido(estado, accion) {
estado.usuario = null;
estado.cargando = false;
estado.error = accion.payload;
},
comprobacionTerminada(estado) {
// Se ha comprobado el almacenamiento local y no había sesión
estado.cargando = false;
}
},
selectors: {
seleccionarUsuario: (estado) => estado.usuario,
seleccionarEsOperario: (estado) => estado.usuario?.rol === 'operario',
seleccionarCargandoSesion: (estado) => estado.cargando,
seleccionarErrorSesion: (estado) => estado.error
}
});
export const { sesionIniciada, sesionCerrada, accesoFallido, comprobacionTerminada } =
sliceSesion.actions;
export const {
seleccionarUsuario,
seleccionarEsOperario,
seleccionarCargandoSesion,
seleccionarErrorSesion
} = sliceSesion.selectors;
export default sliceSesion.reducer;Decisiones que conviene justificar:
sesionIniciadarecibe el usuario completo, no su identificador. Buscar el usuario enusuarioses una operación de búsqueda que puede fallar y que, cuando haya API de verdad, será asíncrona: no es trabajo del reductor. Si prefieres la comodidad de llamar con el identificador, es un caso perfecto paraprepare.cargando: truecomo valor inicial es elcargandoSesionde 06-05, y sigue siendo imprescindible: empezar enfalseharía queRutaProtegidaexpulsara a un usuario con sesión válida durante el primer render.esOperariono está en el estado, es un selector. Es el estado derivado de 07-01 y 05-01 aplicado a Redux: se calcula, no se guarda. Si estuviera guardado, habría que acordarse de actualizarlo en las cuatro transiciones.
sliceReservas: crear, confirmar y cancelar
sliceReservas: crear, confirmar y cancelarEl slice central, ya en forma normalizada (el porqué está en el apartado 10).
// src/funcionalidades/reservas/sliceReservas.js
import { createSlice } from '@reduxjs/toolkit';
const ESTADO_INICIAL = {
entidades: {}, // { 'res-01': { id, bicicletaId, usuario, fechaInicio, horas, estado }, … }
ids: [], // orden de presentación: ['res-01', …]
estadoCarga: 'inactivo', // 'inactivo' | 'cargando' | 'correcto' | 'error'
error: null, // mensaje del último fallo
estadoEnvio: 'inactivo' // 'inactivo' | 'enviando' | 'enviado' | 'error'
};
const sliceReservas = createSlice({
name: 'reservas',
initialState: ESTADO_INICIAL,
reducers: {
reservaCreada: {
prepare(bicicletaId, usuarioId, fechaInicio, horas) {
return {
payload: {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId,
usuario: usuarioId,
fechaInicio,
horas,
estado: 'activa',
creadaEn: new Date().toISOString()
}
};
},
reducer(estado, accion) {
const reserva = accion.payload;
estado.entidades[reserva.id] = reserva;
estado.ids.push(reserva.id);
estado.estadoEnvio = 'enviado';
estado.error = null;
}
},
reservaConfirmada(estado, accion) {
const reserva = estado.entidades[accion.payload];
// Guarda defensiva: la acción podría llegar con un id que ya no existe
if (!reserva) return;
// Solo una reserva activa puede confirmarse
if (reserva.estado !== 'activa') return;
reserva.estado = 'confirmada';
},
reservaCancelada: {
prepare(idReserva) {
return { payload: { idReserva, momento: new Date().toISOString() } };
},
reducer(estado, accion) {
const { idReserva, momento } = accion.payload;
const reserva = estado.entidades[idReserva];
if (!reserva) return;
if (reserva.estado === 'cancelada') return;
reserva.estado = 'cancelada';
reserva.canceladaEn = momento;
}
},
envioIniciado(estado) {
estado.estadoEnvio = 'enviando';
estado.error = null;
},
envioFallido(estado, accion) {
estado.estadoEnvio = 'error';
estado.error = accion.payload;
}
},
selectors: {
seleccionarIdsReservas: (estado) => estado.ids,
seleccionarEntidadesReservas: (estado) => estado.entidades,
seleccionarReservaPorId: (estado, id) => estado.entidades[id],
seleccionarEstadoEnvio: (estado) => estado.estadoEnvio,
seleccionarErrorReservas: (estado) => estado.error
}
});
export const {
reservaCreada, reservaConfirmada, reservaCancelada, envioIniciado, envioFallido
} = sliceReservas.actions;
export const {
seleccionarIdsReservas, seleccionarEntidadesReservas,
seleccionarReservaPorId, seleccionarEstadoEnvio, seleccionarErrorReservas
} = sliceReservas.selectors;
export default sliceReservas.reducer;El ciclo de vida de una reserva queda modelado así:
stateDiagram-v2
[*] --> activa: reservaCreada
activa --> confirmada: reservaConfirmada
activa --> cancelada: reservaCancelada
confirmada --> cancelada: reservaCancelada
cancelada --> [*]
Dos detalles que marcan la diferencia entre un reductor correcto y uno frágil:
- Las guardas devuelven sin hacer nada (
if (!reserva) return;). Dentro decreateSlice, unreturnsin valor significa «no cambia nada», e Immer devuelve el mismo estado. No confundir conreturn undefinedexplícito en un reductor escrito a mano, donde sería un fallo. if (reserva.estado !== 'activa') return;codifica una regla de negocio en el único sitio donde puede aplicarse siempre. Confirmar una reserva ya cancelada deja de ser posible desde ningún punto de la aplicación, y esa garantía es exactamente lo que se compra con el principio 2.
Fíjate también en que el borrador del formulario no está en este slice, a diferencia del reductorReservas de 05-05. Es una decisión deliberada de la auditoría de 07-01: el borrador es estado de formulario, cambia en cada pulsación y no tiene por qué provocar un ciclo de comparación en todos los suscriptores del almacén. Se queda junto al formulario.
- Selectores: la frontera con la forma del estado
Un selector es una función que recibe el estado y devuelve una porción o un derivado de él.
Parece trivial, y sin embargo resuelve un problema muy caro. Sin selectores, los componentes conocen la forma exacta del estado:
// ❌ Sin selectores: veinte componentes acoplados a la forma del almacén
const usuario = useSelector((estado) => estado.sesion.usuario);
const reservas = useSelector((estado) => estado.reservas.ids.map((id) => estado.reservas.entidades[id]));El día que sesion pase a llamarse autenticacion, o que las reservas dejen de estar normalizadas, hay que buscar y cambiar veinte componentes, con la certeza de olvidar dos. Con selectores, la forma del estado la conoce un solo fichero: el del slice.
Por qué se declaran junto al slice: el slice es el único módulo que puede cambiar la forma del estado, así que es el único que debe conocerla. Si cambia, se actualizan sus selectores y nadie más se entera. Es encapsulación clásica, aplicada al estado.
RTK ofrece la clave selectors dentro de createSlice, y tiene una particularidad muy útil: los selectores declarados ahí reciben el estado del slice, no el estado global, y RTK los adapta automáticamente para que desde fuera se llamen con el estado completo.
// Dentro del slice, 'estado' es el estado de sesion
selectors: {
seleccionarUsuario: (estado) => estado.usuario // no estado.sesion.usuario
}
// Desde fuera se usan con el estado global, y RTK hace la traducción
seleccionarUsuario(almacen.getState()); // funcionaEsto funciona porque createSlice sabe bajo qué clave se montará el slice... siempre que la clave del reducer en configureStore coincida con el name del slice. Es otro motivo para que coincidan.
Selectores derivados
Los interesantes son los que calculan en lugar de solo leer. Este es el que gobierna el catálogo de CicloUrbano:
// src/funcionalidades/catalogo/selectores.js
import { seleccionarBicicletas, seleccionarTipo, seleccionarTermino, seleccionarOrden }
from './sliceCatalogo.js';
/**
* Bicicletas que deben verse, aplicando tipo, término y orden.
* Es estado DERIVADO: no se guarda en el almacén, se calcula.
*/
export function seleccionarBicicletasVisibles(estado) {
const bicicletas = seleccionarBicicletas(estado);
const tipo = seleccionarTipo(estado);
const termino = seleccionarTermino(estado).trim().toLowerCase();
const orden = seleccionarOrden(estado);
const filtradas = bicicletas.filter((bici) => {
const coincideTipo = tipo === 'todos' || bici.tipo === tipo;
const coincideTermino = termino === '' || bici.modelo.toLowerCase().includes(termino);
return coincideTipo && coincideTermino;
});
return [...filtradas].sort((a, b) =>
orden === 'precio' ? a.precioHora - b.precioHora : a.modelo.localeCompare(b.modelo)
);
}Esto es 07-01 llevado a Redux: visibles no se guarda, se calcula. No hay ninguna acción visiblesActualizadas ni ningún efecto que sincronice, así que es imposible que el filtro y la lista se contradigan.
Pero este selector tiene un problema que hay que anunciar aquí y resolver en la próxima lección: devuelve un array nuevo en cada llamada, porque filter y sort crean arrays nuevos. Cuando lo uses con useSelector, la comparación por identidad dará siempre «ha cambiado» y el componente se repintará con cada acción del almacén, aunque no tenga nada que ver con el catálogo. La solución es createSelector, que memoriza el resultado y solo recalcula si cambian sus entradas. Se explica a fondo en 07-05, donde el fallo se reproduce y se corrige.
Selectores con argumento
seleccionarReservaPorId recibe un argumento además del estado:
Es perfectamente válido y muy útil. Ten en cuenta que un selector así no se puede memorizar con createSelector tal cual, porque la memorización guarda un único resultado y cambiar de identificador lo invalidaría cada vez. Para eso existen las fábricas de selectores, un patrón que también se ve en 07-05.
- Normalizar el estado por identificador
Los dos slices con entidades —reservas ahora, y bicicletas mientras esté aquí— usan la forma { entidades, ids } en lugar de un array. Esto es normalización, y el motivo se ve mejor comparando.
// SIN normalizar: un array
{
reservas: [
{ id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', horas: 2, estado: 'activa' }
]
}
// NORMALIZADO: entidades por id + orden aparte
{
entidades: {
'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', horas: 2, estado: 'activa' }
},
ids: ['res-01']
}| Operación | Con array | Normalizado |
|---|---|---|
| Buscar por id | reservas.find(r => r.id === id) — recorre la lista |
entidades[id] — acceso directo |
| Actualizar una | map que crea un array nuevo entero |
entidades[id].estado = … — toca una rama |
| Insertar | [...reservas, nueva] |
Dos escrituras puntuales |
| Borrar | filter sobre todo el array |
delete + un filter sobre los ids (cadenas) |
| Repintados provocados | Todos los que lean la lista | Solo quienes lean esa entidad |
La última fila es la decisiva. Con un array, confirmar una reserva crea un array nuevo y todo componente que lea la lista se repinta. Normalizado, cambia una rama de entidades y las demás conservan su referencia (apartado 3), así que un componente que lea entidades['res-07'] no ve ningún cambio.
Y el motivo de fondo: nada de datos anidados. Fíjate en que la reserva guarda bicicletaId: 'bici-002' y usuario: 'usr-01', no la bicicleta ni el usuario completos. Si guardara el objeto entero:
- El modelo de
bici-002estaría duplicado en cada reserva que la use, y actualizar el precio obligaría a recorrerlas todas. - Dos reservas podrían mostrar datos distintos de la misma bicicleta.
- Una única fuente de verdad dejaría de existir, contradiciendo el principio 1.
La regla es la misma que en una base de datos relacional: cada entidad una vez, y las relaciones por identificador. Recomponer el objeto completo para pintarlo es trabajo de un selector derivado, no del estado.
ids aparte cumple una función que no se ve a primera vista: guarda el orden. Las claves de un objeto no garantizan un orden útil, así que la secuencia de presentación se representa explícitamente como un array de cadenas, barato de copiar y de comparar.
createEntityAdapter en breve
createEntityAdapter en breveEscribir a mano entidades[id] = x; ids.push(id) en cada reductor es repetitivo y fácil de desincronizar. RTK trae createEntityAdapter, que genera esas operaciones.
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
const adaptadorReservas = createEntityAdapter({
// Cómo se ordenan los ids. Sin esta opción, orden de inserción.
sortComparer: (a, b) => b.fechaInicio.localeCompare(a.fechaInicio)
});
const sliceReservas = createSlice({
name: 'reservas',
// getInitialState genera { ids: [], entities: {} } y añade lo tuyo
initialState: adaptadorReservas.getInitialState({
estadoCarga: 'inactivo',
error: null
}),
reducers: {
// Los métodos del adaptador SON reductores válidos
reservaAnadida: adaptadorReservas.addOne,
reservasRecibidas: adaptadorReservas.setAll,
reservaActualizada: adaptadorReservas.updateOne, // { id, changes: { estado: 'confirmada' } }
reservaEliminada: adaptadorReservas.removeOne
}
});
// Y genera los selectores básicos, ya memorizados
export const {
selectAll: seleccionarTodasLasReservas,
selectById: seleccionarReservaPorId,
selectIds: seleccionarIdsReservas
} = adaptadorReservas.getSelectors((estado) => estado.reservas);| Método | Qué hace |
|---|---|
addOne / addMany |
Añade sin tocar las existentes |
setAll |
Sustituye todo el conjunto: lo típico tras cargar del servidor |
upsertOne / upsertMany |
Añade o fusiona si ya existe |
updateOne |
Aplica cambios parciales: { id, changes } |
removeOne / removeAll |
Elimina |
Nota importante sobre nombres: el adaptador usa entities e ids en inglés, porque son de la API de RTK. Es el mismo criterio de siempre: las APIs no se traducen. Por eso los slices escritos a mano de esta lección usan entidades, que es campo nuestro, y si adoptas el adaptador el campo pasa a llamarse entities.
En CicloUrbano, con cinco bicicletas y unas pocas reservas, el adaptador es más maquinaria de la necesaria y por eso los slices de arriba están escritos a mano: se ve mejor lo que ocurre. En una aplicación con miles de entidades y media docena de tipos, createEntityAdapter ahorra mucho código y elimina toda una familia de fallos por desincronización entre ids y entidades.
- Lógica asíncrona con
createAsyncThunk
createAsyncThunkLos reductores son puros: no pueden hacer peticiones. Las peticiones se hacen en un thunk, una función que se despacha en lugar de un objeto y que recibe dispatch y getState. RTK envuelve el patrón en createAsyncThunk.
// src/funcionalidades/catalogo/sliceCatalogo.js (continuación)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
/**
* Carga el catálogo de bicicletas desde la API ficticia.
* (La API con json-server se monta en 07-06; aquí ya se usa su URL.)
*/
export const cargarBicicletas = createAsyncThunk(
'catalogo/cargarBicicletas', // prefijo de los tres tipos generados
async (estacionId, utilidades) => { // función que devuelve una promesa
const { signal, rejectWithValue } = utilidades;
const url = estacionId
? `http://localhost:3001/bicicletas?estacionId=${estacionId}`
: 'http://localhost:3001/bicicletas';
const respuesta = await fetch(url, { signal }); // cancelable: ver abajo
if (!respuesta.ok) {
return rejectWithValue(`El servidor respondió ${respuesta.status}`);
}
return respuesta.json(); // será el payload de 'fulfilled'
}
);createAsyncThunk genera tres tipos de acción a partir del prefijo:
| Acción generada | Cuándo se despacha | payload |
|---|---|---|
catalogo/cargarBicicletas/pending |
Justo al empezar | El argumento, en meta.arg |
catalogo/cargarBicicletas/fulfilled |
Al resolverse la promesa | Lo que devuelva la función |
catalogo/cargarBicicletas/rejected |
Al lanzar o rechazar | El error, o lo pasado a rejectWithValue |
sequenceDiagram
participant C as Componente
participant T as cargarBicicletas
participant A as Almacén
participant S as API :3001
C->>T: dispatch(cargarBicicletas('est-01'))
T->>A: /pending
A->>A: estadoCarga = 'cargando'
T->>S: GET /bicicletas?estacionId=est-01
alt Respuesta correcta
S-->>T: 200 + JSON
T->>A: /fulfilled con payload
A->>A: bicicletas = payload · estadoCarga = 'correcto'
else Fallo
S-->>T: 500 o error de red
T->>A: /rejected con el mensaje
A->>A: estadoCarga = 'error' · error = mensaje
end
Esas tres acciones no están en reducers porque no las define este slice: las define el thunk. Se manejan en extraReducers.
const sliceCatalogo = createSlice({
name: 'catalogo',
initialState: ESTADO_INICIAL,
reducers: { /* … tipoCambiado, terminoCambiado, ordenCambiado … */ },
extraReducers: (constructor) => {
constructor
.addCase(cargarBicicletas.pending, (estado) => {
estado.estadoCarga = 'cargando';
estado.error = null;
})
.addCase(cargarBicicletas.fulfilled, (estado, accion) => {
estado.bicicletas = accion.payload;
estado.estadoCarga = 'correcto';
})
.addCase(cargarBicicletas.rejected, (estado, accion) => {
estado.estadoCarga = 'error';
// payload si se usó rejectWithValue; si no, el mensaje del error
estado.error = accion.payload ?? accion.error.message;
});
}
});Este patrón estadoCarga + error es el mismo de useFetchBicicletas (05-06), con 'inactivo' | 'cargando' | 'correcto' | 'error' como única variable de fase, y por el mismo motivo: una sola variable hace imposibles los estados contradictorios del tipo «cargando y con error a la vez».
Detalles del thunk que conviene fijar:
utilidades(el segundo parámetro) trae mucho más quesignal:dispatchpara despachar otras acciones,getStatepara leer el estado actual,rejectWithValuepara devolver un error controlado yrequestIdpara identificar esta ejecución concreta.signales unAbortSignal: pasándolo afetch, la petición se cancela si el thunk se aborta. Es elAbortControllerde 05-02, ya montado.conditionevita peticiones duplicadas, un problema que en 05-02 resolviste a mano:
export const cargarBicicletas = createAsyncThunk(
'catalogo/cargarBicicletas',
async (estacionId, { signal }) => { /* … */ },
{
condition(estacionId, { getState }) {
// Si ya se está cargando, no lanzar otra petición
if (getState().catalogo.estadoCarga === 'cargando') return false;
}
}
);- La función devuelve el dato, no lo guarda. El thunk obtiene; el reductor coloca. Mantener esa separación es lo que permite probar cada parte por separado.
Un aviso honesto, que es el hilo hacia el final del módulo: todo esto —carga, error, cancelación, evitar duplicados— es infraestructura que estás escribiendo a mano para cada recurso. Y aún faltan la revalidación, la invalidación tras escribir, los reintentos y la caché. Esa lista completa es la que justifica la lección 07-06.
extraReducers para reaccionar a otros slices
extraReducers para reaccionar a otros slicesextraReducers no sirve solo para thunks: sirve para que un slice responda a cualquier acción, incluidas las de otro dominio. Es la respuesta a la señal 4 de 07-02 («varios proveedores necesitan reaccionar al mismo suceso»).
Caso concreto: al cerrar sesión, las reservas del usuario deben desaparecer del almacén.
// src/funcionalidades/reservas/sliceReservas.js
import { sesionCerrada } from '../sesion/sliceSesion.js';
const sliceReservas = createSlice({
name: 'reservas',
initialState: ESTADO_INICIAL,
reducers: { /* … */ },
extraReducers: (constructor) => {
constructor
.addCase(sesionCerrada, () => ESTADO_INICIAL) // devolver estado nuevo: correcto
.addMatcher(
(accion) => accion.type.endsWith('/rejected'),
(estado, accion) => {
estado.error = accion.payload ?? accion.error?.message ?? 'Fallo desconocido';
}
)
.addDefaultCase((estado) => estado);
}
});| Método del constructor | Qué hace |
|---|---|
addCase(accion, reductor) |
Responde a un tipo concreto. Debe ir antes que los addMatcher |
addMatcher(predicado, reductor) |
Responde a todas las acciones que cumplan una condición |
addDefaultCase(reductor) |
Responde a lo que no encajó en nada anterior |
Lo importante conceptualmente: una acción se despacha una vez y todos los reductores la ven. sesionCerrada la define sliceSesion y la consume también sliceReservas, sin que ninguno de los dos conozca al otro más allá del import del creador de acción. Es el desacoplamiento que el contexto no daba: allí habrías tenido que pasar una función de un proveedor a otro.
Cuidado con una dependencia circular: sliceReservas importa sesionCerrada de sliceSesion. Si sliceSesion importara algo de sliceReservas, tendrías un ciclo. La convención habitual es que las acciones «transversales» vivan en el slice que las origina y que los demás las importen, nunca al revés.
- Probar reductores y selectores
Aquí se cobra la promesa del principio 3: reductores y selectores son funciones puras, así que probarlas es llamarlas y comparar la salida. Sin React, sin DOM, sin almacén, sin simulaciones.
// src/funcionalidades/reservas/sliceReservas.test.js
import reductor, { reservaConfirmada, reservaCancelada, reservaCreada } from './sliceReservas.js';
const ESTADO_CON_UNA = {
entidades: {
'res-01': {
id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01',
fechaInicio: '2026-05-04T09:00', horas: 2, estado: 'activa'
}
},
ids: ['res-01'],
estadoCarga: 'correcto',
error: null,
estadoEnvio: 'inactivo'
};
test('confirma una reserva activa', () => {
const siguiente = reductor(ESTADO_CON_UNA, reservaConfirmada('res-01'));
expect(siguiente.entidades['res-01'].estado).toBe('confirmada');
});
test('no confirma una reserva ya cancelada', () => {
const cancelada = reductor(ESTADO_CON_UNA, reservaCancelada('res-01'));
const intento = reductor(cancelada, reservaConfirmada('res-01'));
expect(intento.entidades['res-01'].estado).toBe('cancelada');
});
test('no muta el estado recibido', () => {
reductor(ESTADO_CON_UNA, reservaConfirmada('res-01'));
expect(ESTADO_CON_UNA.entidades['res-01'].estado).toBe('activa'); // intacto
});
test('ignora una reserva inexistente', () => {
const siguiente = reductor(ESTADO_CON_UNA, reservaConfirmada('res-99'));
expect(siguiente).toBe(ESTADO_CON_UNA); // misma referencia: no cambió nada
});
test('crea una reserva en estado activa', () => {
const siguiente = reductor(ESTADO_CON_UNA, reservaCreada('bici-004', 'usr-01', '2026-05-06T10:00', 3));
expect(siguiente.ids).toHaveLength(2);
const nueva = siguiente.entidades[siguiente.ids[1]];
expect(nueva.estado).toBe('activa');
expect(nueva.bicicletaId).toBe('bici-004');
});Fíjate en dos pruebas especialmente valiosas:
- «no muta el estado recibido» verifica el principio 3 directamente. Es la red de seguridad frente a un futuro refactor que saque el reductor de
createSlicey pierda Immer. - «ignora una reserva inexistente» comprueba con
toBeque la referencia es la misma. Es la prueba de que la guarda funciona y de que no se generan estados nuevos innecesarios, lo que a su vez evita repintados.
Los selectores derivados se prueban igual de fácil:
import { seleccionarBicicletasVisibles } from './selectores.js';
test('filtra por tipo y ordena por precio', () => {
const estado = {
catalogo: {
tipo: 'electrica', termino: '', orden: 'precio',
bicicletas: [
{ id: 'bici-002', modelo: 'Eléctrica Pro', tipo: 'electrica', precioHora: 4.0 },
{ id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', precioHora: 2.5 },
{ id: 'bici-005', modelo: 'Eléctrica Pro', tipo: 'electrica', precioHora: 4.0 }
],
estadoCarga: 'correcto', error: null
}
};
const visibles = seleccionarBicicletasVisibles(estado);
expect(visibles).toHaveLength(2);
expect(visibles.every((b) => b.tipo === 'electrica')).toBe(true);
});Esta es una de las ventajas más infravaloradas de Redux: toda la lógica de negocio queda en funciones puras que se prueban sin montar un solo componente. Las herramientas concretas —Jest, aserciones, cobertura— son el Módulo 9; lo que interesa hoy es que el diseño lo hace posible.
Errores Comunes y Consejos
Error 1: mutar y devolver a la vez. estado.x = 1; return {…estado} rompe la única regla de Immer. O una cosa, o la otra.
Error 2: reasignar el parámetro estado. estado = ESTADO_INICIAL no hace nada. Para reiniciar, return ESTADO_INICIAL.
Error 3: generar identificadores o fechas dentro del reductor. Rompe la pureza y con ella el viaje en el tiempo y las pruebas. Va en prepare.
Error 4: guardar estado derivado. Un campo visibles, total o esOperario en el estado es un campo que algún día se contradirá con su fuente. Selector.
Error 5: anidar entidades completas. Guardar la bicicleta entera dentro de cada reserva duplica datos y rompe la fuente única de verdad. Guarda bicicletaId y recompón en un selector.
Error 6: nombrar las acciones como órdenes. catalogo/setTipo describe una asignación; catalogo/tipoCambiado describe un suceso. Con la segunda forma, DevTools se lee como una crónica de lo que hizo el usuario.
Error 7: poner los addMatcher antes que los addCase. El constructor de extraReducers los evalúa en orden y RTK avisa si los mezclas mal. Primero los casos concretos, luego los matcher, y addDefaultCase al final.
Error 8: initialState compartido por referencia. Si varios slices comparten el mismo objeto literal y alguno lo devuelve tal cual desde un reinicio, un cambio posterior podría afectar a los dos. Define una constante por slice.
Consejo 1: un fichero por slice, y el slice dueño de su forma. Estado, reductores y selectores juntos. Nadie fuera debería escribir estado.reservas.entidades.
Consejo 2: escribe primero el estado inicial, comentado. Decidir la forma antes que las transiciones evita rehacer los reductores tres veces. Los comentarios de tipo ('activa' | 'confirmada' | 'cancelada') valen su peso en oro.
Consejo 3: prueba cada reductor nuevo el mismo día. Cuesta dos minutos porque son funciones puras, y esas pruebas siguen siendo válidas cuando el componente que las usa se reescriba entero.
Consejo 4: pon guardas en los reductores que buscan por id. if (!entidad) return; convierte un fallo en un no-cambio. Sin la guarda, una acción con un identificador obsoleto tumba la aplicación.
Ejercicios
Ejercicio 1. Añade a sliceReservas una acción reservaProlongada que sume horas a una reserva. Requisitos: solo puede prolongarse una reserva 'activa'; el máximo son 24 horas en total; hay que registrar el momento de la prolongación; y si la reserva no existe o no cumple las condiciones, el estado no debe cambiar. Escribe también dos pruebas.
Ejercicio 2. Este slice tiene cinco problemas. Encuéntralos y reescríbelo.
import { createSlice } from '@reduxjs/toolkit';
const sliceEstaciones = createSlice({
name: 'estaciones',
initialState: {
lista: [],
seleccionada: null,
totalPlazas: 0,
ultimaConsulta: new Date()
},
reducers: {
SET_ESTACIONES(estado, accion) {
estado.lista = accion.payload;
estado.totalPlazas = accion.payload.reduce((s, e) => s + e.plazas, 0);
return { ...estado, ultimaConsulta: new Date() };
},
seleccionarEstacion(estado, accion) {
const encontrada = estado.lista.find((e) => e.id === accion.payload);
estado.seleccionada = encontrada;
},
reiniciar(estado) {
estado = { lista: [], seleccionada: null, totalPlazas: 0, ultimaConsulta: new Date() };
}
}
});Ejercicio 3. Escribe un createAsyncThunk llamado enviarReserva que envíe una reserva nueva a POST http://localhost:3001/reservas y maneje sus tres estados en sliceReservas usando el campo estadoEnvio. Debe: leer el usuario actual del almacén en vez de recibirlo como argumento, devolver un error controlado si la respuesta no es correcta, y añadir la reserva al estado normalizado cuando el servidor la confirme.
Soluciones
Solución 1.
// Dentro de reducers, en sliceReservas
reservaProlongada: {
prepare(idReserva, horasExtra) {
return { payload: { idReserva, horasExtra, momento: new Date().toISOString() } };
},
reducer(estado, accion) {
const { idReserva, horasExtra, momento } = accion.payload;
const reserva = estado.entidades[idReserva];
if (!reserva) return; // no existe
if (reserva.estado !== 'activa') return; // solo las activas
if (horasExtra <= 0) return; // prolongar cero o menos no tiene sentido
const total = reserva.horas + horasExtra;
if (total > 24) return; // límite del dominio
reserva.horas = total;
reserva.prolongadaEn = momento;
}
}test('prolonga una reserva activa dentro del límite', () => {
const siguiente = reductor(ESTADO_CON_UNA, reservaProlongada('res-01', 3));
expect(siguiente.entidades['res-01'].horas).toBe(5); // 2 + 3
expect(siguiente.entidades['res-01'].prolongadaEn).toBeDefined();
});
test('no prolonga por encima de 24 horas', () => {
const siguiente = reductor(ESTADO_CON_UNA, reservaProlongada('res-01', 30));
expect(siguiente).toBe(ESTADO_CON_UNA); // misma referencia: no cambió nada
});El momento va en prepare porque new Date() no puede estar en el reductor, y el límite de 24 horas se codifica en el reductor —no en el componente— para que ninguna pantalla futura pueda saltárselo.
Solución 2. Los cinco problemas:
SET_ESTACIONES: nombre en estilo de constante y con forma de orden. Debe serestacionesRecibidas, un suceso en pasado.totalPlazases estado derivado guardado en el almacén. Es una suma sobrelista: va en un selector.SET_ESTACIONESmuta y devuelve en el mismo reductor: rompe la regla de Immer.new Date()en dos sitios: eninitialStatey en los reductores. No es serializable y no es puro. Cadena ISO, y generada enprepare.reiniciarreasigna el parámetro, así que no hace nada. Debe devolver el estado inicial.
Y un sexto de propina: seleccionada guarda el objeto completo, duplicando la entidad. Debe guardar el identificador y recomponerse con un selector.
import { createSlice } from '@reduxjs/toolkit';
const ESTADO_INICIAL = {
lista: [],
idSeleccionada: null, // 6) solo el id
ultimaConsulta: null // 4) cadena ISO o null
};
const sliceEstaciones = createSlice({
name: 'estaciones',
initialState: ESTADO_INICIAL,
reducers: {
estacionesRecibidas: { // 1) suceso en pasado
prepare(estaciones) { // 4) el momento se genera fuera del reductor
return { payload: { estaciones, momento: new Date().toISOString() } };
},
reducer(estado, accion) { // 3) solo muta el borrador, no devuelve
estado.lista = accion.payload.estaciones;
estado.ultimaConsulta = accion.payload.momento;
}
},
estacionSeleccionada(estado, accion) {
estado.idSeleccionada = accion.payload;
},
estacionesReiniciadas() {
return ESTADO_INICIAL; // 5) devolver, no reasignar
}
},
selectors: {
seleccionarEstaciones: (estado) => estado.lista,
// 2) derivado: se calcula, no se guarda
seleccionarTotalPlazas: (estado) =>
estado.lista.reduce((suma, est) => suma + est.plazas, 0),
// 6) la entidad completa se recompone aquí
seleccionarEstacionSeleccionada: (estado) =>
estado.lista.find((est) => est.id === estado.idSeleccionada) ?? null
}
});
export const { estacionesRecibidas, estacionSeleccionada, estacionesReiniciadas } =
sliceEstaciones.actions;
export const { seleccionarEstaciones, seleccionarTotalPlazas, seleccionarEstacionSeleccionada } =
sliceEstaciones.selectors;
export default sliceEstaciones.reducer;Solución 3.
// src/funcionalidades/reservas/sliceReservas.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const enviarReserva = createAsyncThunk(
'reservas/enviarReserva',
async (borrador, { getState, rejectWithValue, signal }) => {
// El usuario se lee del almacén: no hace falta que lo pase el componente
const usuario = getState().sesion.usuario;
if (!usuario) {
return rejectWithValue('Debes iniciar sesión para reservar.');
}
const cuerpo = {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId: borrador.bicicletaId,
usuario: usuario.id,
fechaInicio: borrador.fechaInicio,
horas: Number(borrador.horas),
estado: 'activa'
};
try {
const respuesta = await fetch('http://localhost:3001/reservas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cuerpo),
signal
});
if (!respuesta.ok) {
return rejectWithValue(`No se ha podido crear la reserva (${respuesta.status}).`);
}
return respuesta.json(); // el servidor devuelve la reserva creada
} catch (fallo) {
if (fallo.name === 'AbortError') throw fallo; // la cancelación no es un error de negocio
return rejectWithValue('No hay conexión con el servidor.');
}
},
{
// Evita el doble envío por doble pulsación
condition(borrador, { getState }) {
if (getState().reservas.estadoEnvio === 'enviando') return false;
}
}
);
// Y en extraReducers de sliceReservas:
extraReducers: (constructor) => {
constructor
.addCase(enviarReserva.pending, (estado) => {
estado.estadoEnvio = 'enviando';
estado.error = null;
})
.addCase(enviarReserva.fulfilled, (estado, accion) => {
const reserva = accion.payload;
estado.entidades[reserva.id] = reserva; // estado normalizado
estado.ids.push(reserva.id);
estado.estadoEnvio = 'enviado';
estado.error = null;
})
.addCase(enviarReserva.rejected, (estado, accion) => {
estado.estadoEnvio = 'error';
estado.error = accion.payload ?? accion.error.message;
});
}Tres decisiones que merecen comentario:
getState()dentro del thunk evita que cada componente tenga que leer el usuario y pasarlo. La regla que hay detrás: el thunk es el sitio donde vive la lógica que necesita leer el estado; el reductor solo aplica el resultado.rejectWithValuefrente a lanzar. Lanzando,accion.error.messagetrae el mensaje técnico del error (Failed to fetch); conrejectWithValuecontrolas exactamente el texto que verá el usuario. Para mensajes de interfaz, siemprerejectWithValue.- La reserva la construye el thunk, no el reductor ni
prepare, porque quien manda es la respuesta del servidor: elfulfilledguarda lo que devolvió la API, no lo que se envió. Si el servidor asignara su propio identificador, esto sería imprescindible.
Conclusión
El almacén de CicloUrbano ya tiene comportamiento. Sabes que una acción es { type, payload }, que se nombra dominio/sucesoOcurrido en pasado —reservas/reservaConfirmada, catalogo/tipoCambiado, sesion/sesionCerrada— y que ese nombrado es lo que convierte el historial de DevTools en una crónica legible. createSlice recibe name, initialState y reducers y genera de una sola vez los tipos, los creadores de acción, el reductor y el caso por defecto: de los tres o cuatro ficheros del Redux clásico se pasa a uno. Immer te deja escribir estado.entidades[id].estado = 'confirmada' dentro de un slice porque lo que tocas es un borrador que anota cambios y produce un objeto nuevo copiando solo las ramas afectadas —lo demás conserva su referencia, que es lo que hará eficiente a useSelector—, con una única regla inviolable: o modificas el borrador o devuelves un estado nuevo, nunca las dos cosas. Y prepare mantiene la pureza sacando del reductor lo no determinista, los identificadores y las fechas ISO.
CicloUrbano queda con tres slices completos: sliceCatalogo con tipo, termino y orden más la carga del catálogo, sliceSesion con el usuario, el cargando inicial imprescindible para RutaProtegida y esOperario como selector y no como campo, y sliceReservas con el ciclo activa → confirmada / cancelada, guardas que codifican las reglas de negocio en el único sitio donde no se pueden saltar, y estado normalizado { entidades, ids }: cada entidad una vez, las relaciones por identificador —bicicletaId, no la bicicleta entera— y el orden explícito en un array de cadenas. Los selectores viven junto a su slice porque el slice es el único que debe conocer la forma del estado, y los derivados como seleccionarBicicletasVisibles calculan en lugar de guardar, con la advertencia pendiente de que devuelven un array nuevo en cada llamada. createAsyncThunk resuelve la asincronía con sus tres acciones pending/fulfilled/rejected manejadas en extraReducers con el patrón estadoCarga/error, y extraReducers sirve además para que un slice reaccione a las acciones de otro sin acoplarse a él. Todo ello son funciones puras, así que probarlas es llamarlas y comparar, sin React ni DOM.
Falta la parte que se ve. En la próxima lección conectas el almacén a los componentes con useSelector y useDispatch, y ahí aparece la regla de oro que decide si Redux te va a repintar la aplicación entera o solo lo necesario: la comparación por identidad. Verás el fallo reproducido con seleccionarBicicletasVisibles, sus cuatro soluciones incluida la memorización con createSelector, reescribirás PaginaCatalogo, PaginaReservas, PanelReservas y MenuUsuario, y decidirás con argumentos qué se queda fuera del almacén. La próxima lección es Redux: Conectando a React.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
