Estado persistente en React y Next.js, sin store
localStorage más useState parecen seis líneas de código — hasta que aparecen el SSR, las pestañas, los datos caducos y el JSON corrupto. Lo que exige de verdad una persistencia de producción en React, y el atajo de 1,4 kB.
En algún rincón de toda codebase React hay un hook llamado useLocalStorage: escrito en diez minutos, copiado de un gist, y al que se le confían el tema, el token de autenticación y medio checkout. He auditado suficientes front-ends como para enunciarlo como ley natural.
Esto es a lo que se compromete de verdad ese hook inocente — y por qué la "persistencia" es un problema de ingeniería real, no una ocurrencia de useEffect.
Las seis líneas que escribe todo el mundo
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(
() => JSON.parse(localStorage.getItem(key) ?? 'null') ?? initial
);
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
Cinco problemas de producción, en orden creciente según lo tarde que muerden:
- Next.js lo mata nada más verlo.
localStorageno existe en el servidor; e incluso con guardas, leerlo durante el render hace que el primer render del cliente difiera del HTML del servidor — hola, avisos de hydration mismatch. - Los datos corruptos lanzan excepciones. El storage es editable por el usuario y sobrevive a tus deploys. Basta una entrada malformada y
JSON.parsetumba el árbol de componentes. - Los componentes no se hablan. Dos componentes con la misma clave guardan cada uno su copia de
useState: cambias uno y el otro ni se entera. El "arreglo" típico es un Context — un provider entero, para una cadena en el storage. - Las pestañas no se hablan, los valores se pudren. Sin evento
storage, sin caducidad: el borrador del mes pasado y el token de ayer se sirven como si estuvieran frescos. - Tormentas de escrituras. Persistir un campo de texto en cada pulsación machaca una API síncrona en el hilo principal.
Nada de esto es exótico. Es la misma lista, en cada app, normalmente redescubierta incidente a incidente.
La lista, empaquetada
De ahí nace smart-state, un pequeño hook open source que mantengo yo (~1,4 kB min+gzip, cero dependencias — transparencia total: es mío):
import { useSmartState } from 'smart-state';
const [theme, setTheme] = useSmartState<'light' | 'dark'>('light', {
persist: true,
storageKey: 'theme',
syncTabs: true,
});
Semántica idéntica a useState — initializer perezoso, updater funcional — con toda la lista resuelta: hydration segura en SSR (sin avisos), estado compartido entre todos los componentes de la misma clave sin provider (debajo hay useSyncExternalStore), sincronización entre pestañas y una gestión de errores que se degrada con elegancia en lugar de lanzar excepciones.
Los detalles de producción son opciones, no reescrituras:
// El storage es input no fiable: valídalo
const [user, setUser] = useSmartState<User>(guest, {
persist: true,
storageKey: 'user',
parse: userSchema.parse, // zod, valibot, cualquier cosa que lance
version: 2,
migrate: (old, from) => (from === 1 ? upgradeV1(old) : undefined),
});
// Una sesión que caduca sola
useSmartState('', { persist: true, storageKey: 'token', ttl: 15 * 60 * 1000 });
// Un borrador que no machaca el storage en cada pulsación
useSmartState('', { persist: true, storageKey: 'draft', writeDebounce: 300 });
El trío parse/version/migrate es la parte que más me importa: los datos persistidos cruzan las fronteras de tus releases, así que merecen la misma desconfianza que cualquier input externo. Poquísimas librerías los tratan así.
"¿Y no bastaría con Zustand?"
Rincón de la honestidad: si tu app tiene estado compartido genuinamente complejo — muchos escritores, datos derivados, time-travel en las devtools — un store como Zustand o Redux Toolkit se gana el puesto, y el middleware persist existe. Pero adoptar un store porque necesitas localStorage es el mundo al revés: cargas con una arquitectura entera para conseguir un efecto secundario. Para el tema, los filtros, el borrador y el carrito, un hook de persistencia es la talla correcta — y con 1,4 kB cuesta menos que el readme del store.
Es la misma lección que repito en las revisiones de arquitectura: primero ponle nombre al requisito real. "Compartir", "persistir", "sincronizar" y "cachear" son cuatro problemas distintos que en las reuniones se llaman todos "state management".
Soy arquitecto de software independiente con foco en front-end — smart-state tiene hermanos para Vue y Nuxt que comparten su formato de storage. Si tu codebase React tiene tres useLocalStorage artesanales y un aviso de hydration que nadie investiga, hablemos: la llamada inicial es gratuita.