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

  1. Anatomía de una acción y cómo nombrarla
  2. createSlice a fondo: qué recibe y qué genera
  3. La inmutabilidad aparente de Immer
  4. La regla que no se puede romper
  5. Reductores con preparación: prepare
  6. sliceCatalogo: filtro, búsqueda y orden
  7. sliceSesion: usuario y acceso
  8. sliceReservas: crear, confirmar y cancelar
  9. Selectores: la frontera con la forma del estado
  10. Normalizar el estado por identificador
  11. createEntityAdapter en breve
  12. Lógica asíncrona con createAsyncThunk
  13. extraReducers para reaccionar a otros slices
  14. Probar reductores y selectores

  1. 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 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.

  1. createSlice a fondo: qué recibe y qué genera

Un 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 como payload.
  • El caso del reductor que responde a ese tipo.
  • El default que 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.

  1. La inmutabilidad aparente de Immer

Mira otra vez el reductor del apartado anterior:

tipoCambiado(estado, accion) {
  estado.tipo = accion.payload;   // ¿¿mutación??
}

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:

  1. Antes de llamar a tu reductor, Immer envuelve el estado actual en un borrador (draft), un objeto proxy que parece el estado real.
  2. Tu código escribe sobre el borrador: estado.tipo = 'electrica', estado.ids.push('res-02'), delete estado.entidades['res-01'].
  3. El proxy no aplica esos cambios: los anota.
  4. 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 createSlice y createReducer. Un reductor escrito a mano y pasado directamente a configureStore no tiene borrador: ahí mutar es mutar, y saltará el aviso de immutableCheck que 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.

  1. 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'

  1. Reductores con preparación: prepare

El 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.

  1. sliceCatalogo: filtro, búsqueda y orden

Empezamos 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:

  • filtrosReiniciados no devuelve ESTADO_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.
  • bicicletas está 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ñar createAsyncThunk con 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.

  1. 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:

  • sesionIniciada recibe el usuario completo, no su identificador. Buscar el usuario en usuarios es 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 para prepare.
  • cargando: true como valor inicial es el cargandoSesion de 06-05, y sigue siendo imprescindible: empezar en false haría que RutaProtegida expulsara a un usuario con sesión válida durante el primer render.
  • esOperario no 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.

  1. sliceReservas: crear, confirmar y cancelar

El 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 de createSlice, un return sin valor significa «no cambia nada», e Immer devuelve el mismo estado. No confundir con return undefined explí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.

  1. 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.

const seleccionarUsuario = (estado) => estado.sesion.usuario;

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());   // funciona

Esto 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:

seleccionarReservaPorId: (estado, id) => estado.entidades[id]

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.

  1. 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-002 estarí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.

  1. createEntityAdapter en breve

Escribir 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.

  1. Lógica asíncrona con createAsyncThunk

Los 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 que signal: dispatch para despachar otras acciones, getState para leer el estado actual, rejectWithValue para devolver un error controlado y requestId para identificar esta ejecución concreta.
  • signal es un AbortSignal: pasándolo a fetch, la petición se cancela si el thunk se aborta. Es el AbortController de 05-02, ya montado.
  • condition evita 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.

  1. extraReducers para reaccionar a otros slices

extraReducers 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.

  1. 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 createSlice y pierda Immer.
  • «ignora una reserva inexistente» comprueba con toBe que 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:

  1. SET_ESTACIONES: nombre en estilo de constante y con forma de orden. Debe ser estacionesRecibidas, un suceso en pasado.
  2. totalPlazas es estado derivado guardado en el almacén. Es una suma sobre lista: va en un selector.
  3. SET_ESTACIONES muta y devuelve en el mismo reductor: rompe la regla de Immer.
  4. new Date() en dos sitios: en initialState y en los reductores. No es serializable y no es puro. Cadena ISO, y generada en prepare.
  5. reiniciar reasigna 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.
  • rejectWithValue frente a lanzar. Lanzando, accion.error.message trae el mensaje técnico del error (Failed to fetch); con rejectWithValue controlas exactamente el texto que verá el usuario. Para mensajes de interfaz, siempre rejectWithValue.
  • La reserva la construye el thunk, no el reductor ni prepare, porque quien manda es la respuesta del servidor: el fulfilled guarda 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

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados