redux-persist y el middleware persist: qué guardan realmente
Las dos herramientas dominantes resuelven el mismo problema con arquitecturas opuestas y conviene entender ambas para no atribuirles poderes que no tienen. En Redux, `redux-persist` envuelve el reductor raíz y añade dos acciones propias al ciclo, `PERSIST` y `REHYDRATE`, con un reconciliador que decide cómo se funde lo guardado con el estado inicial. En Zustand, `persist` envuelve el inicializador, se suscribe al store y escribe en cada cambio, exponiendo una API imperativa para gobernar la hidratación. La lección desmonta lo que ambas comparten: escriben en cada actualización, serializan a texto plano y por tanto pierden funciones, fechas, conjuntos y mapas, y necesitan que digas explícitamente qué guardar y cómo fundirlo.
Las dos herramientas que dominan la persistencia en este ecosistema resuelven un problema idéntico con arquitecturas casi opuestas, y esa diferencia no es estética: determina qué puedes observar, qué puedes interceptar y qué falla en silencio. redux-persist se coloca en el único punto de extensión que Redux ofrece para reescribir estado, el reductor, y por eso su rehidratación es una acción como cualquier otra, visible en las herramientas de desarrollo y componible con el resto del sistema. El middleware persist de Zustand se coloca donde Zustand pone todo, envolviendo el inicializador, y por eso su rehidratación no es un evento del sistema sino una llamada a métodos que el propio middleware añade al store. Detrás de esa divergencia hay una coincidencia que importa más que la diferencia: ambos escriben en cada cambio, ambos serializan a texto plano y ambos guardan bastante menos de lo que su usuario cree.
- Explicar cómo
redux-persistintercepta el reductor raíz y qué hacen sus accionesPERSISTyREHYDRATE. - Configurar el middleware
persistde Zustand conpartialize,mergey su API imperativa. - Describir con precisión qué se pierde al serializar el estado a texto plano.
- Elegir y justificar una estrategia de reconciliación entre lo guardado y el estado inicial.
redux-persist: un reductor que envuelve a otro
La pieza central es persistReducer, que recibe una configuración y tu reductor raíz y devuelve otro reductor con el mismo aspecto por fuera. Ese envoltorio hace tres cosas. Intercepta la acción REHYDRATE, que transporta el estado leído del almacén, y en lugar de pasarla al reductor de dentro la resuelve él mismo fundiendo lo guardado con lo que había. Marca cada estado resultante con una clave interna de metadatos donde registra la versión y si la rehidratación ya ocurrió. Y se suscribe al store para escribir el estado nuevo en el almacén después de cada actualización.
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage'
const config = {
key: 'raiz',
version: 1,
storage,
whitelist: ['preferencias', 'ui'], // lista por inclusion, no por exclusion
}
export const store = configureStore({
reducer: persistReducer(config, reductorRaiz),
middleware: (d) =>
d({ serializableCheck: { ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'] } }),
})
export const persistor = persistStore(store)
La segunda pieza, persistStore, es la que arranca el ciclo: despacha PERSIST, lanza la lectura del almacén y, cuando llega el contenido, despacha REHYDRATE con él. Que la rehidratación sea una acción tiene una consecuencia excelente y una molestia. La consecuencia es que aparece en el registro de las herramientas de desarrollo, puede viajarse en el tiempo y puede ser observada por cualquier otro reductor o middleware que quiera reaccionar a ella. La molestia es que su carga útil no siempre es serializable y que la comprobación que Redux Toolkit trae activada por defecto protestará hasta que la excluyas explícitamente.
persistReducer puede aplicarse a cualquier reductor, no solo al raíz, y anidar configuraciones es la forma limpia de dar a cada dominio su propia clave, su propia versión y su propia migración. Una rebanada de preferencias que cambia de forma dos veces al año no debería obligar a subir la versión del estado entero ni a invalidar lo guardado por el resto de la aplicación. El coste es una entrada más en el almacén por cada configuración anidada, lo cual es preferible a un único bloque monolítico que se corrompe entero.
El middleware persist de Zustand: una suscripción con ceremonia
En Zustand no hay reductor que interceptar, así que persist hace lo que hace todo middleware de la librería: envuelve el inicializador. Al ejecutarse, lee el almacén, funde el resultado con el estado inicial y llama a setState; después se suscribe para escribir en cada cambio. La configuración cubre las mismas cuatro decisiones que en Redux —qué, dónde, cómo se funde y cómo evoluciona— con nombres distintos, y añade algo que Redux no ofrece de fábrica: una API imperativa colgada del propio store para gobernar el momento de la hidratación.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export const usePreferencias = create<Preferencias>()(
persist(
(set) => ({ tema: 'claro', idioma: 'es', cambiarTema: (t) => set({ tema: t }) }),
{
name: 'preferencias',
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ tema: s.tema, idioma: s.idioma }),
merge: (guardado, actual) => ({ ...actual, ...(guardado as object) }),
},
),
)
// API imperativa que anade el middleware
usePreferencias.persist.hasHydrated()
usePreferencias.persist.rehydrate()
usePreferencias.persist.clearStorage()
partialize
Elige qué claves salen del estado hacia el almacén. Sin él se guarda todo, funciones incluidas, que desaparecen al serializarse sin avisar.
merge
Decide cómo se funde lo leído con el estado inicial. Su valor por defecto es una fusión superficial, lo que basta para estados planos y falla en los anidados.
createJSONStorage
Adapta cualquier almacén a la forma esperada y se encarga de convertir a texto y de vuelta. Permite usar sesión, base de datos indexada o un almacén asíncrono.
La API persist
hasHydrated, rehydrate, onFinishHydration, setOptions y clearStorage. Es la superficie que hace posible controlar el momento en lugar de sufrirlo.
sequenceDiagram participant U as interaccion participant S as store participant M as middleware persist participant A as almacen del navegador U->>S: set con el estado nuevo S->>M: notifica el cambio M->>M: aplica partialize M->>M: convierte a texto plano M->>A: escribe bajo la clave name Note over M,A: ocurre en cada cambio, sin agrupacion
Qué se guarda de verdad
El punto ciego compartido por ambas herramientas es que ninguna guarda tu estado: guardan la proyección de tu estado sobre un formato de texto que no sabe representar la mayoría de las cosas interesantes de un lenguaje dinámico. Las funciones desaparecen sin error, lo que en Zustand es especialmente traicionero porque las acciones viven dentro del mismo objeto que los datos. Una fecha vuelve convertida en texto y cualquier método que le invoques después fallará. Un conjunto y un mapa vuelven como objetos vacíos, porque el formato no conoce ninguno de los dos. Un valor no numérico o infinito vuelve como nulo. Y un valor indefinido en una propiedad hace que la propiedad simplemente no exista al volver.
Ambas herramientas escriben en cada actualización del estado. Si el store guarda algo que cambia con cada pulsación de tecla, cada tecla dispara una conversión a texto del subconjunto persistido y una escritura síncrona en el almacenamiento local, que bloquea el hilo principal. Con un estado pequeño es imperceptible; con unos cientos de kilobytes es una fuente medible de saltos en la interfaz. redux-persist ofrece una opción de agrupación temporal para espaciar las escrituras; en Zustand hay que envolver el almacén con una versión propia que retrase la escritura. La corrección de fondo, sin embargo, es anterior: casi siempre lo que sobra es el dato de alta frecuencia dentro de la lista de persistidos.
// Un almacen que agrupa escrituras: la misma forma, con retardo.
const conRetardo = (base: Storage, ms = 400): Storage => {
let pendiente: ReturnType<typeof setTimeout> | undefined
return {
...base,
setItem: (k, v) => {
clearTimeout(pendiente)
pendiente = setTimeout(() => base.setItem(k, v), ms)
},
}
}
Reconciliar: la decisión que nadie configura y todos padecen
Cuando el contenido del almacén llega, hay dos objetos sobre la mesa: el estado inicial que define el código de hoy y el estado guardado que escribió el código de ayer. Fundirlos no es una operación obvia, y la estrategia por defecto de ambas herramientas —una fusión superficial de primer nivel— tiene una propiedad que conviene entender: las claves nuevas que el código de hoy define y el almacén no contiene sobreviven, pero solo en el primer nivel. Si una rebanada anidada gana un campo nuevo, la fusión superficial reemplaza la rebanada entera por la versión guardada y el campo nuevo desaparece, con su valor inicial perdido y un valor indefinido en su lugar.
// Fusion profunda solo donde hace falta, y explicita.
merge: (guardado, actual) => {
const g = guardado as Partial<Estado>
return {
...actual,
...g,
tabla: { ...actual.tabla, ...(g.tabla ?? {}) }, // sin esto se pierde lo nuevo
}
},
redux-persist nombra estas estrategias explícitamente: fusión automática de primer nivel, fusión automática de segundo nivel y sustitución dura, que descarta el estado inicial por completo. Elegir mal produce el error más desconcertante de este nivel, porque el síntoma —un campo indefinido en un estado que el código declara con valor— aparece solo en los usuarios que ya tenían datos guardados y nunca en la máquina de quien programa, que suele limpiar el almacén sin darse cuenta mientras trabaja.
Conviene resistirse a la imagen mental que ambas herramientas fomentan, la de una capa transparente que guarda y devuelve lo mismo que le diste, porque esa imagen oculta el hecho estructural que gobierna todo lo demás: al activarla, tu aplicación pasa a tener dos productores de estado con reglas distintas. El primero es el que has diseñado, donde cada transición está descrita por una acción y calculada por una función pura que puedes leer, testear y razonar. El segundo es el almacén, que produce estado sin acción, sin función pura y sin historia, entregando de golpe un objeto cuya forma la fijó una versión del programa que ya no existe. Toda la disciplina de este nivel consiste en someter ese segundo productor a las mismas garantías que exigimos al primero, y por eso las piezas que parecen configuración accesoria son en realidad las que restauran las propiedades perdidas: la selección explícita de claves es el equivalente a declarar el tipo de la entrada, la versión y la migración son el equivalente a validar que esa entrada cumple el contrato vigente, y la estrategia de reconciliación es el equivalente a definir quién gana cuando dos autoridades afirman cosas distintas sobre el mismo campo. Vista así, la diferencia arquitectónica entre las dos herramientas deja de ser un detalle de gusto y se convierte en información: redux-persist expresa la entrada del estado externo como una acción precisamente porque el modelo de Redux no admite que el estado cambie de otra forma, y esa coherencia es la razón de que la rehidratación sea observable, depurable y componible en ese ecosistema. Zustand renuncia a esa coherencia a cambio de simplicidad, y lo compensa entregando control imperativo sobre el momento. Ninguna de las dos elimina el problema, porque el problema no está en la librería: está en que has decidido que un dato sobreviva a la ejecución que lo produjo, y ese dato, al volver, es una entrada externa aunque venga con tu propia letra.
- Activa la persistencia en un store con una fecha, un conjunto, un mapa y una función entre sus campos. Guarda, recarga e inspecciona qué llega de vuelta y con qué tipo.
- Lee el contenido del almacén desde la consola y compáralo carácter a carácter con lo que creías estar guardando. Anota cada diferencia.
- En Redux, excluye las acciones de persistencia de la comprobación de serializabilidad y explica por qué esa comprobación protestaba.
- Provoca el fallo de la fusión superficial: añade un campo nuevo dentro de una rebanada anidada, simula un usuario con datos viejos y observa el valor indefinido.
- Corrige ese fallo eligiendo una estrategia de reconciliación explícita y justifica por qué esa y no otra.
- Mide el coste de escritura poniendo un campo de alta frecuencia en la lista de persistidos, y compara con la misma prueba tras retirarlo o agrupar las escrituras.