Code splitting del store: inyectar reducers a destiempo
Si una parte de la aplicación se carga bajo demanda, su slice no puede estar en el store desde el arranque sin arruinar el propósito del corte: el paquete inicial cargaría la lógica de estado de pantallas que quizá nadie visite. La solución es que el árbol de reducers deje de ser una constante y pase a ser mutable de forma controlada mediante replaceReducer. Esta lección construye el gestor de reducers a mano para entender el mecanismo, lo contrasta con combineSlices y la inyección de endpoints de RTK 2.x, y examina los cuatro problemas que la carga diferida trae consigo: tipos parciales, acciones que llegan antes que su reducer, estado precargado y limpieza al desmontar.
Las cuatro lecciones anteriores trataron la estructura como algo estático: dónde vive cada cosa y quién puede mirar a quién. Esta introduce el tiempo. Si el panel de administración se carga solo cuando alguien lo abre, su código de vista viaja en un paquete aparte, pero su slice suele quedarse en el arranque, importado por el fichero del store, arrastrando al paquete inicial la lógica de estado de una pantalla que la mayoría de los usuarios no verá. El corte de código a medias es un corte que no corta. La salida existe desde las primeras versiones de Redux y está en un método que casi nadie usa: replaceReducer. Con él, el árbol de reducers deja de ser una constante fijada al arrancar y pasa a ser una estructura que crece cuando el código llega, con las consecuencias que eso tiene sobre los tipos, sobre el orden de las acciones y sobre la limpieza.
- Explicar por qué un slice importado en el arranque anula el corte de código de su funcionalidad.
- Construir un gestor de reducers con
replaceReducery describir qué hace y qué no hace ese método. - Usar
combineSlicesy la inyección de endpoints deRTK Querycomo forma idiomática en 2026. - Anticipar los cuatro problemas de la inyección diferida: tipos, orden, precarga y limpieza.
Por qué el slice arruina el corte
Un corte de código funciona por alcanzabilidad: el empaquetador incluye en el paquete inicial todo lo que se pueda alcanzar por importaciones estáticas desde el punto de entrada, y aparta tras una importación dinámica lo que no. El fichero del store es un punto de entrada de facto, porque para componer el árbol de reducers importa todos los slices; en cuanto un slice aparece ahí, su módulo entero —y todo lo que él importe— vuelve al arranque aunque su pantalla esté perfectamente diferida.
El efecto suele ser mayor de lo que parece, porque un slice rara vez viaja solo: arrastra sus tipos, sus adaptadores de entidad, sus thunks y, con ellos, los clientes de datos y las utilidades de validación que esos thunks usan. Es habitual descubrir que la pantalla más pesada del producto está correctamente diferida en su vista y completamente presente en su lógica. Antes de complicar nada conviene medir si el ahorro compensa: la inyección diferida introduce complejidad real, y solo la justifican módulos que pesan de verdad y que una fracción minoritaria de usuarios llega a abrir.
Diferir una ruta con una importación dinámica es una condición necesaria y no suficiente. Lo que decide qué viaja en el paquete inicial es el grafo completo de importaciones estáticas desde la entrada, y el store es uno de sus nodos más conectados precisamente porque su trabajo consiste en conocer a todos los dominios. Cualquier análisis serio del arranque debería empezar mirando qué arrastra el fichero del store, que es donde se concentran las sorpresas.
El gestor de reducers a mano
El mecanismo cabe en unas pocas líneas y merece escribirse una vez, porque explica todo lo que las APIs modernas hacen por debajo. Un gestor mantiene un diccionario de reducers, recombina y llama a replaceReducer cada vez que alguien registra uno nuevo.
import { combineReducers, type Reducer } from '@reduxjs/toolkit'
export function crearGestor(inicial: Record<string, Reducer>) {
const reducers = { ...inicial }
let combinado = combineReducers(reducers)
return {
reducer: (estado: any, accion: any) => combinado(estado, accion),
añadir(clave: string, r: Reducer) {
if (reducers[clave]) return
reducers[clave] = r
combinado = combineReducers(reducers)
},
}
}
// al cargar la pantalla diferida
const gestor = crearGestor({ sesion: sesionReducer })
gestor.añadir('admin', adminReducer)
store.replaceReducer(gestor.reducer)
Conviene entender con precisión qué hace replaceReducer. Sustituye la función reductora del store y despacha una acción interna de reemplazo para que el nuevo árbol se inicialice; no borra el estado existente, porque combineReducers conserva las ramas cuyo reducer sigue presente y da valor inicial a la nueva. Tampoco reprocesa el historial: las acciones anteriores no se vuelven a aplicar sobre el reducer recién llegado, y esa es justamente la fuente del problema de orden que se trata más abajo.
La forma idiomática en 2026
Redux Toolkit incorporó el patrón en su versión 2 con combineSlices, que sustituye a combineReducers y añade un método de inyección. La diferencia con el gestor artesanal no es de mecanismo sino de tipos: combineSlices mantiene el tipo del estado raíz al día, marcando como opcionales las ramas que aún no se han inyectado, y devuelve al inyectar una versión del slice cuyos selectores saben que su rama ya existe.
import { combineSlices } from '@reduxjs/toolkit'
import { sesionSlice } from '../features/sesion/sesionSlice'
// las ramas diferidas se declaran, no se importan
export const rootReducer = combineSlices(sesionSlice).withLazyLoadedSlices()
// dentro del modulo diferido, al cargarse
const adminInyectado = rootReducer.inject(adminSlice)
export const { selectPendientes } = adminInyectado.selectors
RTK Query tiene su propia versión del mismo gesto: un único servicio base creado con createApi y, en cada módulo diferido, un injectEndpoints que le añade sus endpoints cuando el módulo llega. El resultado es que la caché sigue siendo una sola —una sola rama, una sola configuración, una sola invalidación por etiquetas— mientras la definición de los endpoints se reparte entre los paquetes de cada funcionalidad. Es la aplicación más limpia del principio de este nivel: la unidad de código diferido sigue siendo el dominio, y lo que se difiere es su definición, no su infraestructura.
combineSlices
Sustituye a combineReducers y admite inyección posterior con los tipos del estado raíz al día.
inject
Registra la rama al cargarse el módulo y devuelve selectores que ya conocen su porción.
injectEndpoints
Un único servicio base, endpoints repartidos por funcionalidad. Una sola caché, muchas definiciones.
replaceReducer
La primitiva bajo todo lo anterior. Cambia el reducer del store sin perder el estado ya presente.
Los cuatro problemas de llegar tarde
El primero es de tipos. Si una rama puede no existir todavía, el tipo honesto del estado raíz la declara opcional, y todo selector escrito contra ella debe contemplar el caso ausente. La API de inyección resuelve esto devolviendo selectores que asumen la rama presente, lo cual es correcto porque solo se obtienen después de inyectar; escrito a mano, el atajo de mentir en el tipo con una aserción es tentador y convierte un error de compilación en un fallo en ejecución.
El segundo es de orden. Una acción despachada antes de que su reducer exista no se pierde para el registro de las DevTools, pero no produce efecto sobre el estado y no se reprocesa al inyectar. Aparece cuando un middleware, una notificación de servidor o un enlace profundo despachan hacia una funcionalidad que aún no ha cargado. La disciplina que lo evita es sencilla: la inyección debe formar parte de la carga del módulo, no de la primera renderización de su componente, de modo que la rama exista antes de que nada pueda dirigirse a ella.
sequenceDiagram participant U as usuario participant R as router participant M as modulo diferido participant S as store U->>R: navega a la ruta admin R->>M: importacion dinamica M->>S: inject del slice admin S->>S: replaceReducer con el arbol nuevo M->>S: dispatch de acciones de admin Note over S: si el dispatch llega antes del inject no hay efecto
// Precarga de una rama que aun no existe: se guarda y se aplica al inyectar
const pendiente = window.__ESTADO__?.admin
function inyectarAdmin() {
const inyectado = rootReducer.inject(adminSlice)
if (pendiente) store.dispatch(adminSlice.actions.hidratar(pendiente))
return inyectado
}
// Retirada de una rama: sustituir por un reducer que devuelve indefinido
function retirar(clave: string) {
reducers[clave] = () => undefined
store.replaceReducer(combineReducers(reducers))
delete reducers[clave]
}
El tercero es la precarga. El estado inicial que se pasa al crear el store solo alimenta las ramas presentes en ese momento; el resto se descarta salvo que el gestor lo conserve. En una aplicación con renderizado en servidor, esto significa que el estado serializado de una rama diferida debe guardarse aparte y aplicarse en el momento de la inyección, o que esa rama no debe formar parte de la precarga en absoluto. El cuarto es la limpieza: al desmontar la funcionalidad, su rama permanece con el último estado que tuvo. Retirarla es posible sustituyendo el reducer por uno que devuelva indefinido y eliminando la clave, pero hay que hacerlo sabiendo que cualquier selector superviviente encontrará la rama ausente. Casi siempre la decisión sensata es dejarla: la memoria que ocupa es despreciable y la vuelta a la pantalla resulta instantánea.
Queda una consecuencia que no es un problema sino un beneficio inesperado: la inyección obliga a que cada dominio declare explícitamente cuándo entra en el store, y esa declaración es una prueba viva de que su frontera está bien puesta. Un slice que se puede inyectar tarde es un slice del que nadie depende antes de tiempo; si al diferirlo aparecen selectores rotos en otras funcionalidades, no has descubierto un problema de carga diferida, has descubierto un acoplamiento que la tercera lección del nivel te habría hecho ver.
Un módulo diferido puede cargarse dos veces —dos rutas que lo comparten, una recarga en caliente durante el desarrollo, una precarga anticipada— y la inyección debe tolerarlo sin efecto alguno la segunda vez. Tanto combineSlices como cualquier gestor artesanal decente comprueban si la clave ya está registrada y salen sin recombinar. La versión ingenua que reemplaza el reducer cada vez no falla de forma visible, simplemente reinicia la rama con su estado inicial y borra lo que el usuario tenía delante, que es la clase de bug que solo aparece en producción y solo con navegación real.
La tentación tras montar la maquinaria es diferirlo todo, y hay una clase de slice que nunca debe diferirse: los transversales de la lección anterior. Sesión, permisos, preferencias y notificaciones se leen desde el primer instante y desde cualquier pantalla; hacerlos opcionales obliga a todos sus consumidores a contemplar un caso ausente que en la práctica no ocurre nunca, y a cambio ahorra unos pocos kilobytes de un código que se usa siempre. Difiere lo que es grande y minoritario, mantén en el arranque lo que es pequeño y universal.
Es fácil archivar la inyección de reducers como una optimización de tamaño de paquete, y con eso perder lo que revela. El Redux canónico presenta un estado que existe entero desde el primer instante, con todas sus ramas conocidas de antemano y un único punto donde se declaran; esa propiedad da una sensación de totalidad que resulta tranquilizadora y es, en una aplicación grande, una ficción cara. Ninguna aplicación necesita todo su estado a la vez, y el único motivo por el que lo declara junto es que el fichero del store fue escrito cuando el producto tenía tres pantallas. Aceptar que el árbol de reducers es una estructura que evoluciona en el tiempo obliga a plantearse preguntas que la versión estática permitía ignorar: qué significa despachar hacia un dominio que aún no existe, qué es un estado raíz cuyo tipo es parcial, quién es responsable de que una rama esté presente antes de que alguien la lea. Son exactamente las preguntas que se hace cualquier sistema modular con carga dinámica —un núcleo con sus módulos, un servidor con sus extensiones— y responderlas convierte el store en lo que siempre debió ser: no un objeto monolítico definido de una vez, sino un registro con un ciclo de vida, donde cada dominio se da de alta al llegar y responde de su propia porción. Esa es la forma final del argumento que recorre este nivel entero, desde el duck hasta aquí: la unidad de la aplicación es el dominio, y un dominio de verdad no es solo el que tiene su carpeta y su frontera, es el que también decide cuándo entra en escena.
- Analiza tu paquete inicial y localiza qué slices arrastra el fichero del store, con sus dependencias transitivas. Anota el peso de cada uno.
- Elige la funcionalidad más pesada y menos visitada y comprueba si su vista está diferida pero su slice no. Es el caso típico.
- Sustituye la composición del store por
combineSlicescon ramas diferidas declaradas y quita la importación estática de ese slice. - Inyecta el slice en el propio módulo diferido, en el momento de la carga y no en la renderización del componente, y vuelve a medir el paquete inicial.
- Provoca a propósito una acción de ese dominio antes de que la pantalla cargue y observa qué ocurre en las DevTools. Escribe cómo lo prevendrías.
- Decide de forma razonada si retiras la rama al salir de la pantalla o la conservas, y anota qué selectores tendrías que revisar si eligieras retirarla.