El almacén de CicloUrbano ya tiene forma y comportamiento: tres slices completos, reglas de negocio en los reductores, entidades normalizadas, selectores junto a su dominio y carga asíncrona con createAsyncThunk. Todo eso existe hoy sin que ningún componente lo sepa. Esta lección cierra el circuito con react-redux, y lo hace prestando una atención desproporcionada a un solo asunto, porque es el que separa una integración que funciona de una que repinta la aplicación entera con cada acción: la regla de oro de la comparación por identidad en useSelector. Vas a ver cómo se suscribe un componente y cuándo vuelve a ejecutarse, reproducir el fallo del selector que devuelve un array nuevo y corregirlo de cuatro formas distintas, reescribir PaginaCatalogo, PaginaReservas, PanelReservas y MenuUsuario sobre el almacén, decidir con argumentos qué se queda fuera de Redux, despachar thunks mostrando carga y error, aprender a leer connect en código heredado, depurar con el historial de DevTools y organizar el código por funcionalidad. Se cierra con una comparación honesta entre contexto + reductor, Redux Toolkit y Zustand para una aplicación como esta.
Contenido
useSelector: cómo se suscribe un componente- La regla de oro: comparación por identidad
- El fallo reproducido
- Las cuatro soluciones
createSelectoren la prácticauseDispatchy por quédespachares estableMenuUsuariosobresliceSesionPaginaCatalogo: el filtro de la URL y el almacén conviviendoPaginaReservasyPanelReservassobresliceReservas- Despachar thunks desde un componente
- Qué se queda fuera de Redux
connectymapStateToProps: solo para leer código heredado- Depuración con Redux DevTools
- Organización del código: carpeta por funcionalidad
- Comparativa final honesta
useSelector: cómo se suscribe un componente
useSelector: cómo se suscribe un componenteimport { useSelector } from 'react-redux';
function DistintivoReservas() {
const cantidad = useSelector((estado) => estado.reservas.ids.length);
return <span className="distintivo">{cantidad}</span>;
}Lo que hace useSelector por dentro, paso a paso:
- Se suscribe al almacén mediante
almacen.subscribe(...), usando elProviderque colocaste enmain.jsx(07-03). - Ejecuta la función selectora con el estado actual y devuelve el resultado.
- Tras cada acción despachada, vuelve a ejecutar la función selectora con el estado nuevo.
- Compara el resultado nuevo con el anterior usando
Object.is(comparación por identidad de referencia). - Si son distintos, provoca un repintado del componente. Si son iguales, no hace nada.
Ese paso 5 es la selección granular que el contexto no podía dar (07-02). Un componente suscrito a estado.reservas.ids.length no se repinta cuando cambia el tema, cuando se escribe en el buscador o cuando se confirma una reserva sin alterar el número.
Y el paso 3 es el que hay que tener siempre presente: la función selectora se ejecuta tras cada acción, sea del dominio que sea. sesion/sesionIniciada, catalogo/terminoCambiado, reservas/reservaConfirmada: todas hacen que se ejecuten todos los selectores de todos los componentes montados. Los selectores deben ser, por tanto, baratos.
flowchart TD
A["dispatch(cualquierAccion)"] --> B["El reductor produce<br/>el estado nuevo"]
B --> C["El almacén notifica<br/>a TODOS los suscriptores"]
C --> D["Cada useSelector ejecuta<br/>su función selectora"]
D --> E{"Object.is(nuevo, anterior)"}
E -- "true" --> F["No hay repintado"]
E -- "false" --> G["Repintado del componente"]
- La regla de oro: comparación por identidad
useSelectorcompara conObject.is. Si tu selector construye un objeto o un array nuevo en cada llamada, la comparación siempre dará «ha cambiado» y el componente se repintará con cada acción del almacén, venga de donde venga.
Es la misma trampa de identidad que viste en 07-02 con el value de un proveedor, en otro sitio y con otro nombre. Y aquí es peor, porque un componente mal suscrito se repinta con acciones que no tienen nada que ver con él.
| Lo que devuelve el selector | ¿Se repite la identidad? | Consecuencia |
|---|---|---|
Un número, una cadena, un booleano, null |
Sí, si el valor es igual | ✅ Correcto |
Una referencia guardada en el estado (estado.sesion.usuario) |
Sí, mientras el reductor no la sustituya | ✅ Correcto |
Un objeto literal { a, b } |
No, nunca | ❌ Repintado en cada acción |
El resultado de .map(), .filter(), .sort(), .slice() |
No, nunca | ❌ Repintado en cada acción |
Object.values(...), [...algo] |
No, nunca | ❌ Repintado en cada acción |
La fila de «una referencia guardada en el estado» explica por qué la normalización de 07-04 es tan buena idea: como Immer reutiliza las referencias de las ramas que no cambian, estado.reservas.entidades['res-01'] es literalmente el mismo objeto mientras nadie toque esa reserva, y la comparación por identidad funciona sola.
- El fallo reproducido
Vamos a ver el problema con el selector derivado de 07-04, que es exactamente el caso peligroso.
// ❌ VERSIÓN CON FALLO
import { useSelector } from 'react-redux';
import { seleccionarBicicletasVisibles } from '../funcionalidades/catalogo/selectores.js';
function PaginaCatalogo() {
const visibles = useSelector(seleccionarBicicletasVisibles);
console.log('PaginaCatalogo se ha ejecutado');
return <ListaBicicletas bicicletas={visibles} />;
}Recuerda el selector: termina con [...filtradas].sort(...). Devuelve un array nuevo cada vez.
Prueba a hacer esto en el navegador con DevTools abierto:
- Escribe una letra en el buscador →
catalogo/terminoCambiado→ se ejecuta el componente. Correcto: el catálogo ha cambiado. - Pulsa el botón de tema... y aquí no pasa nada, porque el tema no está en Redux. Bien pensado.
- Inicia sesión →
sesion/sesionIniciada→ se ejecutaPaginaCatalogo. El catálogo no ha cambiado en absoluto. - Confirma una reserva →
reservas/reservaConfirmada→ se ejecutaPaginaCatalogootra vez.
Con el registro en consola verás la línea aparecer con cada acción de la aplicación. Y no es solo un render: como visibles es un array nuevo, ListaBicicletas recibe una prop nueva y también se repinta, con todas sus TarjetaBicicleta dentro.
En desarrollo, react-redux te avisa por consola:
Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders.
Es un aviso muy fácil de ignorar y muy caro de ignorar.
- Las cuatro soluciones
Solución 1: seleccionar valores primitivos
La más simple, la más rápida y la que se olvida más a menudo.
// ❌ Devuelve un objeto nuevo
const { nombre, rol } = useSelector((estado) => ({
nombre: estado.sesion.usuario?.nombre,
rol: estado.sesion.usuario?.rol
}));
// ✅ Dos primitivas, cada una comparable por valor
const nombre = useSelector((estado) => estado.sesion.usuario?.nombre);
const rol = useSelector((estado) => estado.sesion.usuario?.rol);Cuando lo que necesitas son dos o tres valores sueltos, varios useSelector de primitivas siempre ganan. No hay memorización que mantener, no hay dependencias que declarar y cada uno solo repinta cuando su valor cambia de verdad.
Solución 2: dividir en varios useSelector
La generalización de la anterior. Un componente puede tener tantos useSelector como necesite; el coste de cada suscripción es despreciable.
function PanelReservas() {
const ids = useSelector(seleccionarIdsReservas); // array guardado en el estado
const estadoEnvio = useSelector(seleccionarEstadoEnvio); // cadena
const error = useSelector(seleccionarErrorReservas); // cadena o null
// …
}Aquí ids es seguro porque es el array que vive en el estado, no uno construido al vuelo. Immer solo lo sustituye cuando se añade o se quita una reserva.
Solución 3: memorizar con createSelector
Cuando el selector tiene que calcular de verdad —filtrar, ordenar, agregar—, la respuesta es memorizarlo. Es el apartado 5 entero.
Solución 4: función de igualdad personalizada
useSelector acepta un segundo argumento: una función que decide si dos resultados son iguales.
import { useSelector, shallowEqual } from 'react-redux';
// Compara las propiedades de primer nivel en vez de la referencia
const { nombre, rol } = useSelector(
(estado) => ({ nombre: estado.sesion.usuario?.nombre, rol: estado.sesion.usuario?.rol }),
shallowEqual
);shallowEqual compara clave a clave con Object.is. El objeto sigue siendo nuevo en cada llamada, pero como su contenido superficial es igual, useSelector no repinta.
RTK ofrece además useSelector con createSelector ya integrado, y react-redux exporta useDebugValue-friendly helpers, pero en la práctica basta con estas cuatro opciones.
Cuál elegir:
| Situación | Solución |
|---|---|
| Necesitas uno o varios valores sueltos | 1 y 2: primitivas, un useSelector por valor |
| Necesitas una referencia que ya está en el estado | Selecciónala directamente: es segura |
| Necesitas un cálculo (filtrar, ordenar, agregar) | 3: createSelector |
| Necesitas un objeto agrupado y no quieres memorizar | 4: shallowEqual |
Orden de preferencia: 1 → 2 → 3 → 4. La cuarta es la última porque shallowEqual recorre el objeto en cada comparación y no evita el trabajo del selector, solo el repintado.
createSelector en la práctica
createSelector en la prácticacreateSelector viene de Reselect y RTK lo reexporta. Recibe una lista de selectores de entrada y una función combinadora, y memoriza: si las entradas son idénticas a las de la llamada anterior, devuelve el resultado anterior sin recalcular y con la misma referencia.
// src/funcionalidades/catalogo/selectores.js
import { createSelector } from '@reduxjs/toolkit';
import {
seleccionarBicicletas, seleccionarTermino, seleccionarOrden
} from './sliceCatalogo.js';
/**
* Bicicletas visibles del catálogo.
* El TIPO no sale del almacén: llega como argumento porque vive en la URL.
*/
export const seleccionarBicicletasVisibles = createSelector(
[
seleccionarBicicletas, // entrada 1: estado.catalogo.bicicletas
seleccionarTermino, // entrada 2: estado.catalogo.termino
seleccionarOrden, // entrada 3: estado.catalogo.orden
(estado, tipo) => tipo // entrada 4: el filtro de la URL
],
(bicicletas, termino, orden, tipo) => {
const buscado = termino.trim().toLowerCase();
const filtradas = bicicletas.filter((bici) => {
const coincideTipo = tipo === 'todos' || bici.tipo === tipo;
const coincideTermino = buscado === '' || bici.modelo.toLowerCase().includes(buscado);
return coincideTipo && coincideTermino;
});
return [...filtradas].sort((a, b) =>
orden === 'precio' ? a.precioHora - b.precioHora : a.modelo.localeCompare(b.modelo)
);
}
);Qué ocurre ahora en el escenario del apartado 3:
| Acción despachada | ¿Cambian las entradas? | Resultado |
|---|---|---|
sesion/sesionIniciada |
No | Devuelve la misma referencia. Object.is da true. Sin repintado |
reservas/reservaConfirmada |
No | Igual. Sin repintado |
catalogo/terminoCambiado |
Sí, termino |
Recalcula, array nuevo, repintado. Correcto |
Cambio de ?tipo= en la URL |
Sí, la cuarta entrada | Recalcula. Correcto |
Tres advertencias importantes sobre createSelector:
1. Los selectores de entrada deben ser baratos y estables. Se ejecutan siempre; lo que se ahorra es la función combinadora. Si una entrada devolviera un objeto nuevo cada vez, la memorización nunca acertaría y el selector sería peor que no tenerlo.
2. La memorización tiene tamaño. Históricamente Reselect guardaba un solo resultado, así que un selector con argumento usado desde dos componentes con argumentos distintos se invalidaba mutuamente en cada llamada. En Reselect 5 —el que trae RTK 2— la memorización por defecto (weakMapMemoize) maneja bien varios argumentos y ese problema desaparece en la mayoría de casos. Si necesitas el comportamiento clásico con varios consumidores, el patrón es una fábrica de selectores:
// Fábrica: cada componente crea su propia instancia memorizada
export const crearSelectorReservasDeUsuario = () =>
createSelector(
[seleccionarEntidadesReservas, seleccionarIdsReservas, (estado, usuarioId) => usuarioId],
(entidades, ids, usuarioId) => ids.map((id) => entidades[id]).filter((r) => r.usuario === usuarioId)
);
// En el componente
function ReservasDeUsuario({ usuarioId }) {
// useMemo mantiene la MISMA instancia entre renders (08-03)
const selector = useMemo(crearSelectorReservasDeUsuario, []);
const reservas = useSelector((estado) => selector(estado, usuarioId));
// …
}3. No memorices lo que no cuesta. createSelector alrededor de (estado) => estado.sesion.usuario es ruido: no hay cálculo que ahorrar y la referencia ya es estable. Memoriza cuando el selector construya algo.
createSelector y useMemo resuelven el mismo problema en capas distintas: uno memoriza sobre el estado del almacén y sirve para todos los componentes; el otro memoriza dentro de un componente. El detalle de cuándo compensa memorizar está en 08-03.
useDispatch y por qué despachar es estable
useDispatch y por qué despachar es estableimport { useDispatch } from 'react-redux';
import { reservaConfirmada } from '../funcionalidades/reservas/sliceReservas.js';
function BotonConfirmar({ idReserva }) {
const despachar = useDispatch();
function manejarClic() {
despachar(reservaConfirmada(idReserva));
}
return <button type="button" onClick={manejarClic}>Confirmar</button>;
}useDispatch devuelve la función dispatch del almacén, que es siempre la misma: el almacén se crea una vez, en almacen.js, y su dispatch no cambia jamás durante la vida de la aplicación.
Consecuencias prácticas:
- Un componente que solo despacha nunca se repinta por Redux. No está suscrito a nada. Es el equivalente automático de la división estado/acciones que en 07-02 hubo que montar a mano con dos contextos.
despacharpuede ir en las dependencias de unuseEffectsin causar bucles. De hecho, el linter de hooks te pedirá que lo incluyas, y es correcto hacerlo.- No hace falta memorizar los manejadores que solo despachan por miedo a la identidad: la función que provoca el trabajo es estable.
Fíjate en el patrón de nombres del proyecto aplicado a Redux: manejarClic es el manejador interno del componente y reservaConfirmada es el creador de acción importado del slice. La convención de «manejadores manejarX dentro, props alX hacia fuera» sigue vigente; lo que cambia es que ahora, en lugar de invocar una prop alConfirmar, muchos componentes despachan directamente.
Un matiz de diseño que merece pensarse: no todos los componentes deberían despachar. Un TarjetaBicicleta que despacha acciones deja de ser reutilizable y deja de poder probarse sin un almacén. La regla razonable es que las páginas y los paneles se conectan; los componentes de presentación reciben props. ListaBicicletas y TarjetaBicicleta siguen recibiendo bicicletas y alSeleccionar como hasta ahora.
MenuUsuario sobre sliceSesion
MenuUsuario sobre sliceSesionEl caso más sencillo, y el que enseña la solución 1.
// src/componentes/MenuUsuario.jsx
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import { sesionCerrada } from '../funcionalidades/sesion/sliceSesion.js';
import { seleccionarUsuario, seleccionarEsOperario }
from '../funcionalidades/sesion/sliceSesion.js';
import { useAlternar } from '../hooks/useAlternar.js';
import estilos from './MenuUsuario.module.css';
function MenuUsuario() {
// Primitivas y referencias del estado: comparación por identidad segura
const usuario = useSelector(seleccionarUsuario);
const esOperario = useSelector(seleccionarEsOperario);
const despachar = useDispatch();
const navegar = useNavigate();
// Estado local de interfaz: NO va al almacén
const [abierto, alternarAbierto] = useAlternar(false);
function manejarCerrarSesion() {
despachar(sesionCerrada());
alternarAbierto();
navegar('/', { replace: true });
}
if (!usuario) {
return <Link to="/acceso" className={estilos.acceso}>Identificarse</Link>;
}
return (
<div className={estilos.menu}>
<button type="button" onClick={alternarAbierto} aria-expanded={abierto}>
{usuario.nombre}
</button>
{abierto && (
<ul className={estilos.desplegable}>
{esOperario && <li><Link to="/taller">Taller</Link></li>}
<li><Link to="/reservas">Mis reservas</Link></li>
<li><button type="button" onClick={manejarCerrarSesion}>Cerrar sesión</button></li>
</ul>
)}
</div>
);
}
export default MenuUsuario;Tres puntos que resumen media lección:
seleccionarEsOperariodevuelve un booleano. Es estado derivado calculado en el selector (07-04), y al ser primitivo la comparación por identidad funciona perfectamente.abiertose queda enuseAlternar. Es exactamente el ejemplo del apartado 11.sesionCerrada()se llama sin argumentos y devuelve{ type: 'sesion/sesionCerrada' }. Y recuerda de 07-04 quesliceReservasreacciona a esa misma acción en suextraReducerslimpiando las reservas: un solo despacho, dos dominios actualizados, sin acoplamiento entre componentes.
PaginaCatalogo: el filtro de la URL y el almacén conviviendo
PaginaCatalogo: el filtro de la URL y el almacén conviviendoEsta es la decisión de diseño más interesante de la lección, y estaba pendiente desde 07-04.
El problema: ?tipo= vive en la URL desde 06-02 y sliceCatalogo tiene un campo tipo. Dos fuentes de verdad para el mismo dato es exactamente lo que 07-01 prohíbe.
La decisión: el tipo se queda en la URL y desaparece del slice. La URL es la fuente de verdad porque el filtro debe poder compartirse por enlace y sobrevivir a un recargado, y ningún almacén de cliente da eso. El slice conserva termino y orden, que no son enlazables por decisión de producto, y seleccionarBicicletasVisibles recibe el tipo como argumento, tal como se escribió en el apartado 5.
// src/paginas/PaginaCatalogo.jsx — con Redux y la URL conviviendo
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import BuscadorBicicletas from '../componentes/BuscadorBicicletas.jsx';
import SelectorTipo from '../componentes/SelectorTipo.jsx';
import ListaBicicletas from '../componentes/ListaBicicletas.jsx';
import IndicadorDeCarga from '../componentes/IndicadorDeCarga.jsx';
import Aviso from '../componentes/Aviso.jsx';
import { cargarBicicletas, terminoCambiado, seleccionarTermino,
seleccionarEstadoCargaCatalogo, seleccionarErrorCatalogo }
from '../funcionalidades/catalogo/sliceCatalogo.js';
import { seleccionarBicicletasVisibles } from '../funcionalidades/catalogo/selectores.js';
function PaginaCatalogo() {
const [parametros, establecerParametros] = useSearchParams();
const despachar = useDispatch();
// FUENTE DE VERDAD DEL FILTRO: la URL, como en 06-02
const tipoElegido = parametros.get('tipo') ?? 'todos';
// Del almacén: primitivas y un selector memorizado con argumento
const termino = useSelector(seleccionarTermino);
const estadoCarga = useSelector(seleccionarEstadoCargaCatalogo);
const error = useSelector(seleccionarErrorCatalogo);
const visibles = useSelector((estado) => seleccionarBicicletasVisibles(estado, tipoElegido));
// Estado compartido entre pocos: se queda local (07-01)
const [idSeleccionada, setIdSeleccionada] = useState(null);
useEffect(() => {
despachar(cargarBicicletas());
}, [despachar]);
function manejarCambioDeTipo(nuevoTipo) {
const siguientes = new URLSearchParams(parametros);
if (nuevoTipo === 'todos') siguientes.delete('tipo');
else siguientes.set('tipo', nuevoTipo);
establecerParametros(siguientes, { replace: true });
}
function manejarBuscar(texto) {
despachar(terminoCambiado(texto));
}
if (estadoCarga === 'cargando') {
return <IndicadorDeCarga mensaje="Cargando el catálogo…" />;
}
return (
<section>
<h1>Catálogo de bicicletas</h1>
{error && <Aviso tono="error" texto={error} />}
<BuscadorBicicletas valor={termino} alBuscar={manejarBuscar} />
<SelectorTipo valor={tipoElegido} alCambiar={manejarCambioDeTipo} />
<ListaBicicletas
bicicletas={visibles}
idSeleccionada={idSeleccionada}
alSeleccionar={setIdSeleccionada}
/>
</section>
);
}
export default PaginaCatalogo;Lo que hay que retener de este componente:
- Tres fuentes de estado coexisten sin duplicarse: el filtro en la URL, el término y el catálogo en el almacén, y la selección en un
useStatelocal. Cada uno donde le corresponde según la taxonomía de 07-01. useEffectcon[despachar]como única dependencia funciona precisamente porquedespachares estable. Yconditiondel thunk (07-04) evita que el doble render deStrictModeprovoque dos peticiones.ListaBicicletasySelectorTipono conocen Redux. Siguen recibiendo props. Son componentes de presentación y así se mantienen probables y reutilizables.- La alternativa que NO se ha elegido: mantener
tipoen el slice y sincronizarlo con la URL mediante unuseEffect. Habría funcionado casi siempre, y habría fallado en los casos difíciles —pegar una URL, el botón «atrás», abrir en una pestaña nueva—, además de meter un render extra en cada cambio de filtro. Cuando hay dos candidatos a fuente de verdad, se elige uno y el otro deja de existir.
PaginaReservas y PanelReservas sobre sliceReservas
PaginaReservas y PanelReservas sobre sliceReservas// src/paginas/PaginaReservas.jsx
import { useSelector } from 'react-redux';
import { seleccionarIdsReservas } from '../funcionalidades/reservas/sliceReservas.js';
import PanelReserva from '../componentes/PanelReserva.jsx';
function PaginaReservas() {
// Referencia del estado, no un array construido: seguro
const ids = useSelector(seleccionarIdsReservas);
if (ids.length === 0) {
return <p>Todavía no tienes ninguna reserva. <Link to="/reservas/nueva">Crear una</Link></p>;
}
return (
<section>
<h1>Mis reservas</h1>
<ul>
{ids.map((id) => <PanelReserva key={id} idReserva={id} />)}
</ul>
</section>
);
}
export default PaginaReservas;// src/componentes/PanelReserva.jsx — cada fila se suscribe a SU reserva
import { useSelector, useDispatch } from 'react-redux';
import { reservaConfirmada, reservaCancelada, seleccionarReservaPorId }
from '../funcionalidades/reservas/sliceReservas.js';
import EtiquetaEstado from './EtiquetaEstado.jsx';
function PanelReserva({ idReserva }) {
const reserva = useSelector((estado) => seleccionarReservaPorId(estado, idReserva));
const despachar = useDispatch();
if (!reserva) return null;
return (
<li>
<h2>{reserva.bicicletaId}</h2>
<EtiquetaEstado estado={reserva.estado} />
<p>{reserva.horas} h desde {reserva.fechaInicio.replace('T', ' ')}</p>
{reserva.estado === 'activa' && (
<>
<button type="button" onClick={() => despachar(reservaConfirmada(idReserva))}>
Confirmar
</button>
<button type="button" onClick={() => despachar(reservaCancelada(idReserva))}>
Cancelar
</button>
</>
)}
</li>
);
}
export default PanelReserva;Este par de componentes ilustra el patrón más eficiente de Redux con listas, y merece explicarse bien:
- El padre selecciona solo los identificadores.
idses el array que vive en el estado; solo cambia de referencia al añadir o quitar una reserva. Confirmar una reserva no repintaPaginaReservas. - Cada hijo selecciona su propia entidad por identificador. Gracias a Immer,
entidades['res-01']conserva su referencia mientras nadie toque esa reserva, así que confirmarres-01repinta exclusivamente la fila deres-01.
Con una lista de doscientas reservas, la diferencia entre este patrón y seleccionar el array completo en el padre es de doscientos renders a uno. Es la aplicación práctica de por qué se normalizó el estado en 07-04.
- Despachar thunks desde un componente
Un thunk se despacha igual que una acción: despachar(cargarBicicletas()). La diferencia es que devuelve una promesa con métodos útiles.
// src/paginas/PaginaNuevaReserva.jsx (fragmento)
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router';
import { enviarReserva, seleccionarEstadoEnvio, seleccionarErrorReservas }
from '../funcionalidades/reservas/sliceReservas.js';
import { useAvisosAcciones } from '../contextos/ContextoAvisos.jsx';
function PaginaNuevaReserva() {
// El borrador es estado de FORMULARIO: se queda local (07-01)
const [borrador, setBorrador] = useState({ bicicletaId: '', fechaInicio: '', horas: 1 });
const estadoEnvio = useSelector(seleccionarEstadoEnvio);
const error = useSelector(seleccionarErrorReservas);
const despachar = useDispatch();
const navegar = useNavigate();
const { mostrarAviso } = useAvisosAcciones();
const enviando = estadoEnvio === 'enviando';
async function manejarEnvio(evento) {
evento.preventDefault();
// unwrap() lanza si el thunk terminó en rejected, y devuelve el payload si no
try {
const reserva = await despachar(enviarReserva(borrador)).unwrap();
mostrarAviso('exito', `Reserva ${reserva.id} creada.`);
navegar('/reservas', { replace: true });
} catch (fallo) {
// El estado del almacén ya recoge el error; aquí solo decidimos la interfaz
mostrarAviso('error', String(fallo));
}
}
return (
<form onSubmit={manejarEnvio} aria-busy={enviando}>
{/* … campos del formulario … */}
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={enviando}>
{enviando ? 'Enviando…' : 'Reservar'}
</button>
</form>
);
}Puntos importantes:
.unwrap()convierte el resultado del thunk en algo con lo que se puede usartry/catch: devuelve elpayloaddelfulfilledo lanza el error delrejected. Sin él,await despachar(...)se resuelve siempre, incluso cuando la petición ha fallado, y elcatchnunca se ejecutaría. Es el fallo más común al despachar thunks.- La carga y el error se leen del almacén, no de un
useStateparalelo. Una única fuente de verdad, y además visible en DevTools. - La navegación y el aviso ocurren en el componente, no en el thunk. Un reductor no navega y un thunk no debería conocer el enrutador: la lógica de interfaz se queda en la interfaz.
disabled={enviando}yaria-busy(03-06) evitan el doble envío desde la interfaz; elconditiondel thunk (07-04) lo evita también desde el almacén. Las dos cosas, no una sola.
- Qué se queda fuera de Redux
Tener un almacén no significa meterlo todo dentro. Esta es la lista razonada para CicloUrbano.
| Dato | ¿En Redux? | Justificación |
|---|---|---|
| Tema visual | No: sigue en ContextoTema |
Cambia una vez por sesión, lo lee un botón y el atributo data-tema del documento. No hay lógica que auditar ni acciones que trazar. Meterlo solo añadiría ruido al historial |
abierto de MenuUsuario, Modal, DialogoReserva |
No: useAlternar local |
Estado local de interfaz. En el almacén ensuciaría DevTools con decenas de acciones irrelevantes y obligaría a comprobar quién más lee ese campo antes de tocarlo |
Filtro ?tipo= |
No: vive en la URL | Debe poder compartirse por enlace y sobrevivir a un recargado. Dos fuentes de verdad es peor que ninguna |
idSeleccionada del catálogo |
No: useState en PaginaCatalogo |
Compartido entre dos hermanos y muere con la pantalla. Elevarlo al almacén sería globalización prematura de manual |
borrador del formulario |
No: useState en la página |
Cambia en cada pulsación. Cada tecla sería una acción, un ciclo de comparación en todos los suscriptores y una entrada en el historial |
| Avisos | No: siguen en ContextoAvisos |
Funcionan bien, están divididos en estado y acciones (07-02) y su vida es puramente visual |
| Sesión | Sí, sliceSesion |
Varias pantallas la leen, tiene transiciones con reglas y otros dominios reaccionan a su cierre |
| Reservas | Sí, sliceReservas |
Reglas de negocio que merecen reductor y prueba, entidades compartidas entre pantallas |
termino y orden |
Sí, sliceCatalogo |
Se comparten entre el buscador y la lista, y deben sobrevivir al ir a una ficha y volver |
| Catálogo de bicicletas | Hoy sí, en 07-06 no | Es estado del servidor. Está en el slice de forma provisional, tal como se anunció en 07-04 |
El criterio que resume la tabla: algo entra en Redux cuando su cambio es un suceso del dominio que merece quedar registrado. «El usuario ha abierto un menú» no lo es. «El usuario ha cancelado una reserva» sí.
connect y mapStateToProps: solo para leer código heredado
connect y mapStateToProps: solo para leer código heredado⚠️ Este apartado existe únicamente para que sepas leer proyectos anteriores a 2019.
connectsigue funcionando yreact-reduxlo mantiene, pero no se usa en código nuevo. No aparecerá más en el curso.
Antes de los hooks, la conexión se hacía con un componente de orden superior:
// ⚠️ CÓDIGO HEREDADO — NO ESCRIBIR ASÍ HOY
import { connect } from 'react-redux';
import { reservaConfirmada, reservaCancelada } from './acciones.js';
function PanelReserva({ reserva, alConfirmar, alCancelar }) {
return (
<li>
<h2>{reserva.bicicletaId}</h2>
<button onClick={() => alConfirmar(reserva.id)}>Confirmar</button>
<button onClick={() => alCancelar(reserva.id)}>Cancelar</button>
</li>
);
}
// Qué del estado se convierte en props
function mapStateToProps(estado, propsPropias) {
return { reserva: estado.reservas.entidades[propsPropias.idReserva] };
}
// Qué acciones se convierten en props ya despachadas
const mapDispatchToProps = {
alConfirmar: reservaConfirmada,
alCancelar: reservaCancelada
};
export default connect(mapStateToProps, mapDispatchToProps)(PanelReserva);Equivalencia con hooks:
| API heredada | Equivalente con hooks | Nota |
|---|---|---|
mapStateToProps(estado) |
useSelector(selector) |
Un useSelector por valor, en vez de un objeto con todo |
mapStateToProps(estado, propsPropias) |
useSelector((e) => selector(e, props.id)) |
Las props se usan directamente, sin segundo parámetro |
mapDispatchToProps como objeto |
const despachar = useDispatch() + despachar(accion()) |
Ya no se envuelven los creadores |
mapDispatchToProps como función |
Igual: despachar(...) en el manejador |
|
mergeProps |
Combinar en el propio componente | Rara vez se usaba |
connect()(Componente) |
Nada: el componente usa los hooks | Desaparece un nivel de envoltura |
ownProps |
Las props normales del componente |
Por qué se abandonó: connect añade un componente envolvente por cada conexión —lo que ensucia el árbol en DevTools—, obliga a separar el componente «tonto» del «conectado», tiene una API con cuatro formas distintas de escribir mapDispatchToProps y es notablemente difícil de tipar. Los hooks hacen lo mismo con menos indirección.
Lo que sí conviene conservar de aquella época es la distinción conceptual entre componentes de presentación y conectados. connect la imponía; con hooks hay que mantenerla por disciplina, y es la regla del apartado 6: páginas y paneles se conectan, los componentes de presentación reciben props.
- Depuración con Redux DevTools
Ahora que la aplicación despacha acciones de verdad, la herramienta de 07-03 vale lo que prometía. Un ejemplo concreto de cómo cambia la forma de buscar un fallo.
El síntoma: «a veces, al confirmar dos reservas seguidas, la segunda aparece cancelada».
Sin Redux, la investigación sería: poner console.log en tres manejadores, intentar reproducirlo, sospechar de un problema de cierre, sospechar de una condición de carrera, añadir más registros.
Con Redux DevTools:
- Reproduce el fallo una vez.
- Mira la lista de acciones. Ahí está toda la secuencia real:
sesion/sesionIniciada catalogo/cargarBicicletas/pending catalogo/cargarBicicletas/fulfilled reservas/reservaConfirmada ← payload: 'res-01' reservas/reservaCancelada ← payload: 'res-02' ⚠️ ¿de dónde sale esto? reservas/reservaConfirmada ← payload: 'res-02' - La acción sobra y está identificada en veinte segundos. El fallo no está en el reductor ni en el estado: hay un
onClickque despachareservaCanceladacuando no debería, probablemente un manejador mal cableado en el botón de la segunda fila. - Pulsa skip sobre esa acción y comprueba que sin ella el resultado es el correcto: confirma el diagnóstico sin tocar el código.
- Mira el panel Diff de la acción sospechosa para ver exactamente qué cambió.
Y si el fallo lo reportó un usuario, puede exportar el historial en JSON y tú importarlo: reproduces su sesión exacta en tu máquina.
Lo que cambia de fondo no es la velocidad, es la naturaleza de la pregunta. Sin Redux preguntas «¿por qué el estado está mal?». Con Redux preguntas «¿qué acción lo dejó así?», y esa pregunta siempre tiene respuesta, porque está en la lista.
Tres funciones que se usan a diario:
| Función | Cuándo |
|---|---|
| Diff | Lo primero que hay que mirar: qué cambió con esa acción y nada más |
| Jump / saltar a una acción | Volver al estado anterior y ver la interfaz de ese instante |
| Skip | Comprobar una hipótesis sin modificar el código |
- Organización del código: carpeta por funcionalidad
Hasta ahora CicloUrbano se organiza por tipo: componentes/, paginas/, hooks/, contextos/, reductores/. Con Redux, la comunidad recomienda otra cosa.
| Por tipo | Por funcionalidad | |
|---|---|---|
| Agrupa | Ficheros que hacen lo mismo | Ficheros que hablan de lo mismo |
| Añadir una funcionalidad | Tocar 5 carpetas | Crear 1 carpeta |
| Borrar una funcionalidad | Buscar por todo el proyecto | Borrar la carpeta |
| Encontrar «todo lo de reservas» | Difícil | Trivial |
| Escala bien hasta | Proyectos pequeños | Cualquier tamaño |
src/ ├── main.jsx ├── rutas.jsx ├── index.css ├── almacen/ │ └── almacen.js # configureStore con el mapa de reductores ├── funcionalidades/ │ ├── reservas/ │ │ ├── sliceReservas.js # estado, reductores, thunks, selectores │ │ ├── sliceReservas.test.js │ │ ├── PaginaReservas.jsx │ │ ├── PaginaNuevaReserva.jsx │ │ ├── PanelReserva.jsx │ │ ├── PanelReserva.module.css │ │ └── FormularioReserva.jsx │ ├── catalogo/ │ │ ├── sliceCatalogo.js │ │ ├── selectores.js # selectores derivados con createSelector │ │ ├── PaginaCatalogo.jsx │ │ ├── PaginaFichaBicicleta.jsx │ │ ├── ListaBicicletas.jsx │ │ ├── TarjetaBicicleta.jsx │ │ └── SelectorTipo.jsx │ ├── sesion/ │ │ ├── sliceSesion.js │ │ ├── PaginaAcceso.jsx │ │ ├── MenuUsuario.jsx │ │ ├── RutaProtegida.jsx │ │ └── RequiereRol.jsx │ └── estaciones/ │ ├── PaginaEstaciones.jsx │ ├── PaginaDetalleEstacion.jsx │ ├── PestanaFlota.jsx │ └── PestanaIncidencias.jsx ├── componentes/ # COMPARTIDOS: no pertenecen a un dominio │ ├── Diseno.jsx │ ├── Cabecera.jsx │ ├── PieDePagina.jsx │ ├── Panel.jsx │ ├── Modal.jsx │ ├── Aviso.jsx │ ├── EtiquetaEstado.jsx │ ├── IndicadorDeCarga.jsx │ ├── MigasDePan.jsx │ └── LimiteDeError.jsx ├── contextos/ # tema y avisos: siguen sin Redux │ ├── ContextoTema.jsx │ ├── ContextoAvisos.jsx │ └── Proveedores.jsx ├── hooks/ # genéricos, sin dominio │ ├── useAlternar.js │ ├── useAlmacenLocal.js │ ├── useDebounce.js │ └── useAnchoVentana.js ├── utilidades/ └── datos/
Reglas que hacen que esto funcione:
componentes/guarda solo lo genuinamente compartido. Si algo lo usa un único dominio, vive en su carpeta.- Un dominio no importa del interior de otro. Si
reservasnecesita algo decatalogo, se importa de la raíz de esa carpeta, y si empieza a pasar mucho, la frontera entre dominios está mal trazada. rutas.jsxsigue en la raíz y sí importa de varios dominios: es, por definición, el mapa que los une.- La migración es gradual. Se mueve un dominio cada vez, y la aplicación sigue funcionando en cada paso.
En un proyecto del tamaño de CicloUrbano la organización por tipo aún se aguanta. La razón para cambiar no es estética: es que la carpeta por funcionalidad hace visible el acoplamiento. Cuando reservas/ necesita cinco cosas de catalogo/, se ve en los import y es una señal de diseño, no un detalle de colocación.
- Comparativa final honesta
Con todo el módulo en la mano, esta es la comparación para una aplicación como CicloUrbano.
Contexto + useReducer |
Redux Toolkit | Zustand | |
|---|---|---|---|
| Dependencias | Ninguna | 2 paquetes, ~15-20 kB | 1 paquete, ~1 kB |
| Código para empezar | Un fichero de contexto | Almacén + un slice por dominio | Un fichero |
| Selección granular | No: repinta todo el consumidor | Sí, con useSelector |
Sí, con selector |
| Herramientas de depuración | No | Excelentes | DevTools de Redux vía complemento |
| Viaje en el tiempo | No | Sí | Limitado |
| Middleware | No | Sí | Sí, más sencillo |
| Asincronía | Manual | createAsyncThunk |
Funciones async normales |
| Ceremonia | Media | Alta | Muy baja |
| Estado fuera de React | No | Sí | Sí |
| Convención de equipo | Hay que inventarla | Impuesta y documentada | Hay que inventarla |
| Curva de aprendizaje | Ya la conoces | Media | Baja |
| Encuentro en el mercado laboral | — | Muy frecuente | Creciente |
Y la recomendación, dicha sin adornos:
| Situación | Elección |
|---|---|
| Proyecto personal, 1 persona, aplicación pequeña | Contexto + useReducer. Ya lo tienes y funciona |
| Equipo de 2-4, aplicación mediana, sin lógica de estado compleja | Zustand, o contexto si el estado global es poco |
| Equipo de 5+, aplicación grande, lógica de negocio que auditar | Redux Toolkit. La convención y las herramientas se pagan solas |
| Aplicación cuyo estado es casi todo datos de servidor | TanStack Query y poco más de estado de cliente (07-06) |
| Proyecto que ya usa Redux clásico | Migrar a RTK gradualmente, slice a slice |
Para CicloUrbano, con honestidad: es una aplicación pequeña con un equipo pequeño, y contexto + useReducer seguiría siendo suficiente. Redux se ha introducido porque es lo que te vas a encontrar en el trabajo, porque el modelo mental —acciones, reductores puros, selectores, estado normalizado— se transfiere íntegro a Zustand y a Jotai, y porque las DevTools son una herramienta que conviene haber usado al menos una vez. Lo que no vamos a hacer es fingir que era imprescindible.
Un último aviso, que es el más importante del módulo y el hilo hacia la próxima lección: cuando 07-06 saque las bicicletas, las estaciones y las reservas del almacén hacia una caché de estado de servidor, lo que quede en Redux será muy poco. En muchas aplicaciones reales, después de ese movimiento el estado de cliente cabe en dos contextos, y esa es una conclusión legítima a la que hay que llegar habiendo entendido Redux, no evitándolo.
Errores Comunes y Consejos
Error 1: un selector que devuelve un objeto o un array construido. El error número uno, con diferencia. Provoca repintados con cada acción de la aplicación. Primitivas, referencias del estado o createSelector.
Error 2: agrupar varios valores en un objeto «por comodidad». useSelector((e) => ({ a: e.x, b: e.y })) sin shallowEqual es el error 1 disfrazado. Dos useSelector son más simples y más rápidos.
Error 3: olvidar .unwrap() al despachar un thunk. await despachar(thunk()) se resuelve siempre, así que el catch no se ejecuta nunca y los fallos se tragan en silencio.
Error 4: seleccionar el array completo en el padre de una lista. Selecciona los identificadores en el padre y la entidad en cada hijo: de N repintados pasas a uno.
Error 5: conectar componentes de presentación. TarjetaBicicleta con useSelector deja de poder reutilizarse y de poder probarse sin almacén. Páginas y paneles se conectan; el resto recibe props.
Error 6: duplicar en Redux algo que ya vive en la URL y sincronizarlo con un efecto. Elige una fuente de verdad y borra la otra.
Error 7: leer el estado con almacen.getState() dentro de un componente. No suscribe a nada, así que el componente no se repinta cuando cambia. getState() es para thunks y middleware.
Consejo 1: activa el aviso de selectores inestables. react-redux ya lo emite en desarrollo. No lo ignores: cada uno de esos avisos es un componente repintándose de más.
Consejo 2: nombra los selectores seleccionarX y expórtalos desde el slice. Un componente no debería escribir nunca estado.reservas.entidades directamente.
Consejo 3: mide con el Profiler antes de memorizar. createSelector en un selector trivial es coste sin beneficio. El Módulo 8 enseña a medirlo (08-05).
Consejo 4: mantén el almacén pequeño a propósito. Cada campo que añades es un campo que alguien tendrá que entender. La tabla del apartado 11 es una herramienta de decisión, no una lista cerrada.
Ejercicios
Ejercicio 1. Este componente provoca un repintado con cada acción del almacén, tiene tres problemas distintos y además falla al enviar. Encuéntralos y reescríbelo.
function ResumenFlota({ estacionId }) {
const { bicicletas, estaciones } = useSelector((estado) => ({
bicicletas: estado.catalogo.bicicletas,
estaciones: estado.estaciones.lista
}));
const deLaEstacion = useSelector((estado) =>
estado.catalogo.bicicletas.filter((b) => b.estacionId === estacionId)
);
const despachar = useDispatch();
async function manejarRecargar() {
try {
await despachar(cargarBicicletas(estacionId));
mostrarAviso('exito', 'Flota actualizada.');
} catch (fallo) {
mostrarAviso('error', 'No se ha podido actualizar.');
}
}
return <Panel titulo={`Flota (${deLaEstacion.length})`}>{/* … */}</Panel>;
}Ejercicio 2. Escribe un selector memorizado seleccionarResumenReservas que devuelva { activas, confirmadas, canceladas, totalHoras } a partir del estado normalizado de sliceReservas, y el componente DistintivoReservas que lo consuma mostrando solo el número de activas. Justifica por qué el componente no debería usar ese selector tal cual y qué harías en su lugar.
Ejercicio 3. Traduce este componente heredado a hooks, conservando el comportamiento.
class ListaEstaciones extends React.Component {
componentDidMount() {
this.props.cargar();
}
render() {
const { estaciones, cargando, alSeleccionar } = this.props;
if (cargando) return <IndicadorDeCarga />;
return (
<ul>
{estaciones.map((e) => (
<li key={e.id} onClick={() => alSeleccionar(e.id)}>{e.nombre}</li>
))}
</ul>
);
}
}
const mapStateToProps = (estado) => ({
estaciones: estado.estaciones.lista,
cargando: estado.estaciones.estadoCarga === 'cargando'
});
const mapDispatchToProps = {
cargar: cargarEstaciones,
alSeleccionar: estacionSeleccionada
};
export default connect(mapStateToProps, mapDispatchToProps)(ListaEstaciones);Soluciones
Solución 1. Los tres problemas de rendimiento y el de envío:
- El primer
useSelectordevuelve un objeto literal. Referencia nueva en cada llamada: repintado con cada acción. - El segundo
useSelectordevuelve el resultado de unfilter. Array nuevo en cada llamada: el mismo problema, por segunda vez. bicicletasyestacionesse seleccionan y no se usan. Suscripción gratuita a dos ramas del estado.- Falta
.unwrap():await despachar(cargarBicicletas(...))se resuelve incluso cuando el thunk termina enrejected, así que elcatchnunca se ejecuta y un fallo de red mostraría «Flota actualizada».
// src/funcionalidades/catalogo/selectores.js
export const crearSelectorBicicletasDeEstacion = () =>
createSelector(
[seleccionarBicicletas, (estado, estacionId) => estacionId],
(bicicletas, estacionId) => bicicletas.filter((b) => b.estacionId === estacionId)
);// src/funcionalidades/estaciones/ResumenFlota.jsx
import { useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { crearSelectorBicicletasDeEstacion } from '../catalogo/selectores.js';
import { cargarBicicletas } from '../catalogo/sliceCatalogo.js';
import { useAvisosAcciones } from '../../contextos/ContextoAvisos.jsx';
function ResumenFlota({ estacionId }) {
const despachar = useDispatch();
const { mostrarAviso } = useAvisosAcciones();
// Una instancia memorizada por componente montado
const selector = useMemo(crearSelectorBicicletasDeEstacion, []);
const deLaEstacion = useSelector((estado) => selector(estado, estacionId));
async function manejarRecargar() {
try {
await despachar(cargarBicicletas(estacionId)).unwrap(); // 4)
mostrarAviso('exito', 'Flota actualizada.');
} catch (fallo) {
mostrarAviso('error', 'No se ha podido actualizar.');
}
}
return (
<Panel titulo={`Flota (${deLaEstacion.length})`}>
<button type="button" onClick={manejarRecargar}>Recargar</button>
{/* … */}
</Panel>
);
}Se han eliminado las dos suscripciones inútiles, el filtro se memoriza con una fábrica —para que dos ResumenFlota de estaciones distintas no se invaliden entre sí— y el envío informa correctamente del fallo.
Solución 2.
// src/funcionalidades/reservas/selectores.js
import { createSelector } from '@reduxjs/toolkit';
import { seleccionarEntidadesReservas, seleccionarIdsReservas } from './sliceReservas.js';
export const seleccionarResumenReservas = createSelector(
[seleccionarEntidadesReservas, seleccionarIdsReservas],
(entidades, ids) => {
const resumen = { activas: 0, confirmadas: 0, canceladas: 0, totalHoras: 0 };
for (const id of ids) {
const reserva = entidades[id];
if (reserva.estado === 'activa') resumen.activas += 1;
if (reserva.estado === 'confirmada') resumen.confirmadas += 1;
if (reserva.estado === 'cancelada') resumen.canceladas += 1;
if (reserva.estado !== 'cancelada') resumen.totalHoras += reserva.horas;
}
return resumen;
}
);Por qué DistintivoReservas no debería usarlo tal cual: el selector devuelve un objeto, y aunque createSelector lo memoriza, se recalcula —y devuelve un objeto nuevo— cada vez que cambia cualquier reserva. Si el distintivo solo muestra el número de activas, se repintaría al cancelar una reserva, al confirmar otra o al cambiar las horas de una tercera, aunque el número de activas siga igual.
La solución es seleccionar la primitiva:
// ✅ Solo se repinta cuando cambia el número de activas
function DistintivoReservas() {
const activas = useSelector((estado) => seleccionarResumenReservas(estado).activas);
if (activas === 0) return null;
return <span className="distintivo" aria-label={`${activas} reservas activas`}>{activas}</span>;
}El selector memorizado hace el cálculo una sola vez y se comparte entre todos los consumidores; cada componente extrae el primitivo que necesita y useSelector compara ese número. Es la combinación de las soluciones 1 y 3, y es el patrón recomendado para selectores que devuelven agregados: memoriza el agregado, selecciona el campo.
Solución 3.
// src/funcionalidades/estaciones/ListaEstaciones.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { cargarEstaciones, estacionSeleccionada, seleccionarEstaciones,
seleccionarEstadoCargaEstaciones } from './sliceEstaciones.js';
import IndicadorDeCarga from '../../componentes/IndicadorDeCarga.jsx';
function ListaEstaciones() {
// mapStateToProps → un useSelector por valor
const estaciones = useSelector(seleccionarEstaciones);
const cargando = useSelector((estado) => seleccionarEstadoCargaEstaciones(estado) === 'cargando');
// mapDispatchToProps → useDispatch
const despachar = useDispatch();
// componentDidMount → useEffect con [] (más despachar, que es estable)
useEffect(() => {
despachar(cargarEstaciones());
}, [despachar]);
if (cargando) return <IndicadorDeCarga mensaje="Cargando estaciones…" />;
return (
<ul>
{estaciones.map((estacion) => (
<li key={estacion.id}>
<button type="button" onClick={() => despachar(estacionSeleccionada(estacion.id))}>
{estacion.nombre}
</button>
</li>
))}
</ul>
);
}
export default ListaEstaciones;Cuatro mejoras que la traducción trae de propina:
- Desaparece el componente envolvente que
connectinsertaba en el árbol. cargandoes un booleano derivado en el selector, no un objeto: comparación por identidad perfecta.<li onClick>pasa a ser un<button>dentro del<li>. El original no era accesible: un<li>no recibe foco ni responde al teclado (03-06). Migrar código heredado es buen momento para arreglar esto.despacharen las dependencias del efecto es correcto y no causa bucles, precisamente por ser estable.
Conclusión
El circuito está cerrado. useSelector suscribe un componente al almacén, ejecuta su función selectora tras cada acción y compara el resultado con Object.is, de donde sale la regla de oro: un selector que construye un objeto o un array nuevo en cada llamada provoca repintados con cada acción de la aplicación, venga del dominio que venga. Las cuatro soluciones, en orden de preferencia: seleccionar primitivas, dividir en varios useSelector, memorizar con createSelector —recordando que los selectores de entrada deben ser baratos y que la memorización solo compensa cuando hay cálculo— y, como último recurso, una función de igualdad como shallowEqual. useDispatch devuelve la función dispatch del almacén, que es estable para siempre: un componente que solo despacha nunca se repinta por Redux, y despachar puede ir en las dependencias de un efecto sin miedo.
CicloUrbano queda conectado con criterio. MenuUsuario lee dos primitivas de sliceSesion y conserva su desplegable en useAlternar. PaginaCatalogo hace convivir tres fuentes de estado sin duplicar ninguna: el filtro en la URL —que sigue siendo la fuente de verdad, con el tipo pasado como argumento al selector memorizado—, el término y el catálogo en el almacén, y la selección en un useState local. PaginaReservas selecciona solo los identificadores y PanelReserva selecciona su propia entidad, de modo que confirmar una reserva repinta exactamente una fila: la recompensa directa de haber normalizado el estado. Los thunks se despachan con .unwrap() para que try/catch funcione, y la carga y el error se leen del almacén, no de un useState paralelo. Y la tabla de qué se queda fuera —tema, modales, filtro de URL, selección del catálogo, borrador del formulario, avisos— resume el criterio de todo el módulo: algo entra en Redux cuando su cambio es un suceso del dominio que merece quedar registrado.
También sabes leer connect, mapStateToProps y mapDispatchToProps sin escribirlos nunca, usar el historial de DevTools para cambiar la pregunta de «¿por qué el estado está mal?» a «¿qué acción lo dejó así?», y organizar el código por funcionalidad en lugar de por tipo, con componentes/ reservado a lo genuinamente compartido. La comparativa final es honesta: para una aplicación del tamaño de CicloUrbano, contexto + useReducer habría bastado; Redux Toolkit se paga solo cuando hay equipo, tamaño y lógica que auditar; y Zustand ocupa un término medio muy razonable.
Queda la pieza más grande, y la que va a cambiar el equilibrio de todo lo anterior. En el almacén hay hoy un campo bicicletas cargado con un createAsyncThunk, y ya se dijo dos veces que estaba ahí de forma provisional: los datos que vienen de un servidor no son estado de la aplicación. No te pertenecen, se quedan obsoletos solos, se comparten entre pestañas y usuarios y llegan tarde. Tratarlos como estado de cliente obliga a escribir a mano carga, error, cancelación, condiciones de carrera, deduplicación, revalidación, invalidación, reintentos y paginación —para cada recurso—. En la próxima lección montarás una API ficticia con json-server, conocerás TanStack Query, entenderás el ciclo de vida de un dato en caché, harás mutaciones con actualización optimista y su reversión, reescribirás useFetchBicicletas como useBicicletas, y fijarás la arquitectura final de CicloUrbano: Redux para el estado del cliente, Query para el del servidor. La próxima lección es Estado del Servidor: Peticiones, Caché y Sincronización.
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
