12k
All articles

Cómo persistir el estado en localStorage con React

Persiste el estado de React en localStorage con un hook reutilizable: useState diferido, JSON try/catch, guardas SSR y sincronización entre pestañas.

OpenReplay Team
OpenReplay Team
Cómo persistir el estado en localStorage con React

Para persistir el estado de React en localStorage, inicializa useState desde el almacenamiento dentro de su función inicializadora y escribe el valor de vuelta cada vez que cambie; luego envuelve el JSON en try/catch y protege el código contra el renderizado en el servidor.

Toda aplicación React termina desarrollando algo así, generalmente para un selector de tema o una barra lateral que debe permanecer contraída. La versión de tres líneas tarda cinco minutos en escribirse y luego te cuesta silenciosamente una tarde entera. Esa versión ingenua funciona para un contador en una sola pestaña, pero falla de tres maneras predecibles: se rompe con datos corruptos, lanza window is not defined en Next.js y queda desactualizada entre pestañas. Este artículo construye un hook useLocalStorage escalando por los peldaños de la corrección, corrigiendo cada modo de fallo en orden, y termina con un hook listo para usar que puedes pegar en un proyecto de React 18 o 19.

localStorage es un almacén de clave/valor sincrónico, del mismo origen y solo de cadenas de texto, de aproximadamente 5MB por origen, documentado en la MDN Web Storage API. Una regla antes de cualquier código: nunca almacenes tokens de autenticación ni información de identificación personal (PII) en él. Es legible por cualquier JavaScript en la página y no está cifrado.

Puntos clave

  • Lee localStorage dentro del inicializador de useState para que la consulta se ejecute una sola vez en el montaje, en lugar de mostrar primero el valor predeterminado a través de un useEffect.
  • Dado que localStorage solo almacena cadenas de texto, persiste los datos con JSON.stringify al escribir y JSON.parse al leer, envueltos en try/catch para que un valor corrupto no pueda romper el componente.
  • En el servidor no existe window, por lo que leer el almacenamiento durante el primer renderizado lanza window is not defined en Next.js y Remix. Renderiza el valor predeterminado en el servidor y sincroniza con el valor persistido después del montaje.
  • El evento storage del navegador solo se dispara en otras pestañas, nunca en la que escribió el valor, por lo que los listeners de la misma pestaña necesitan un evento despachado manualmente.
  • useSyncExternalStore, añadido en React 18, es la forma oficialmente soportada de suscribir un componente a un almacén mutable externo como localStorage.

El patrón ingenuo de localStorage en React

El punto de partida es un useState con inicialización diferida combinado con un efecto de escritura. En React, lee localStorage dentro de la función inicializadora de useState para que la consulta se ejecute una sola vez en el montaje, en lugar de leerlo en un useEffect que mostraría primero el valor predeterminado.

import { useState, useEffect } from 'react';

function ThemeToggle() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
      Theme: {theme}
    </button>
  );
}

Pasar una función a useState (en lugar de useState(localStorage.getItem(...))) es importante: el inicializador diferido solo se ejecuta en el primer renderizado, por lo que evitas acceder a localStorage en cada re-renderizado. Leer en el inicializador en lugar de en un useEffect separado también significa que el valor correcto está presente desde el primer pintado, sin el destello de valor predeterminado seguido del valor persistido.

Serialización segura con JSON y try/catch

La versión ingenua solo maneja cadenas de texto. Dado que localStorage solo almacena cadenas, persiste el estado que no sea de tipo cadena usando JSON.stringify al escribir y JSON.parse al leer, y envuelve el parse en try/catch para que un valor corrupto o heredado no pueda romper el componente. Un modo de fallo habitual en producción es un cambio de esquema o un valor escrito a medias que deja JSON inválido bajo una clave; sin la protección, JSON.parse lanza una excepción en el montaje y deja caer el componente.

function readJSON<T>(key: string, fallback: T): T {
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback; // valor corrupto o heredado → volver al valor predeterminado
  }
}

La rama catch devuelve el valor predeterminado en lugar de propagar el error, que es la diferencia entre que una clave incorrecta restablezca una preferencia y que una clave incorrecta deje la página en blanco.

¿Cómo construir un hook useLocalStorage reutilizable?

Encapsula el patrón en un hook que imite a useState para que sea un reemplazo directo. Para mantener la paridad con useState, el setter de tu useLocalStorage debe aceptar una actualización funcional, de modo que setValue(prev => prev + 1) funcione igual que con el estado integrado. Es la brecha ergonómica que la mayoría de las versiones escritas a mano pasan por alto.

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => readJSON(key, initialValue));

  const set = useCallback(
    (next: T | ((prev: T) => T)) => {
      setValue(prev => {
        const resolved = next instanceof Function ? next(prev) : next;
        localStorage.setItem(key, JSON.stringify(resolved));
        return resolved;
      });
    },
    [key],
  );

  return [value, set] as const;
}

La comprobación next instanceof Function es lo que preserva la ergonomía de useState. Esta versión es correcta en el cliente, pero sigue leyendo localStorage durante el renderizado, lo que falla en el momento en que se renderiza en el servidor.

El problema con SSR: “window is not defined” y el desajuste de hidratación

En el servidor no existe window ni localStorage, por lo que leer el almacenamiento durante el primer renderizado lanza window is not defined en Next.js y Remix. Protege el código con typeof window === 'undefined' y lee el valor persistido después del montaje.

Existe un segundo error más sutil incluso después de detener el fallo. Se produce un desajuste de hidratación porque el servidor renderiza tu estado predeterminado mientras el cliente ya tiene el valor almacenado; el primer renderizado del cliente en React debe coincidir con el HTML del servidor, por lo que si lees localStorage en el inicializador durante la hidratación, el marcado diverge. La solución es renderizar el valor predeterminado en el servidor y luego sincronizar con el valor persistido en un efecto después de la hidratación.

const IS_SERVER = typeof window === 'undefined';

function useLocalStorage<T>(key: string, initialValue: T, initializeWithValue = true) {
  const readValue = () => (IS_SERVER ? initialValue : readJSON(key, initialValue));

  const [value, setValue] = useState<T>(() =>
    initializeWithValue ? readValue() : initialValue,
  );

  useEffect(() => {
    setValue(readValue()); // sincronizar desde el almacenamiento después del montaje
  }, [key]);
  // ...setter como antes
}

El indicador initializeWithValue imita el interruptor del hook useLocalStorage de usehooks-ts: establécelo en false para SSR de modo que el hook devuelva el valor predeterminado en el servidor y sincronice después de la hidratación. Esta clase de error es casi invisible en una carga limpia de localhost. Reproducir una sesión real de producción es a menudo la única forma en que el destello de hidratación (el tema predeterminado que se pinta durante un fotograma antes de que tome el control el valor persistido) se hace realmente visible, ya que depende del tiempo y del entorno en lugar de ser reproducible a demanda.

Sincronización entre pestañas y el enfoque moderno con useSyncExternalStore

El estado persistido debe mantenerse consistente cuando un usuario tiene dos pestañas abiertas. El evento storage del navegador, descrito en MDN’s Window: storage event, solo se dispara en otras pestañas y documentos, nunca en la pestaña que escribió el valor. La sincronización entre pestañas, por tanto, necesita un listener de storage, y los listeners de la misma pestaña necesitan un evento personalizado despachado manualmente.

Para código nuevo, existe una primitiva más limpia que useState + efectos. useSyncExternalStore fue introducido en React 18 como la forma oficial de suscribir un componente a un almacén mutable externo. Los componentes normalmente leen desde props, estado y contexto, pero ocasionalmente uno tiene que leer un valor que vive fuera de React y cambia con el tiempo, incluyendo APIs del navegador que mantienen un valor mutable y emiten eventos cuando cambia. La referencia propia de React para el hook recomienda el estado integrado cuando sea posible y lo reserva principalmente para la integración con código existente que no es de React. localStorage cumple los requisitos, y es por eso que las bibliotecas mantenidas lo adoptaron para lecturas seguras en modo concurrente y correctas entre pestañas.

function useLocalStorageValue(key: string, initial: string) {
  const subscribe = (cb: () => void) => {
    window.addEventListener('storage', cb);
    return () => window.removeEventListener('storage', cb);
  };
  return useSyncExternalStore(
    subscribe,
    () => localStorage.getItem(key) ?? initial,
    () => initial, // snapshot del servidor
  );
}

¿Deberías escribir useLocalStorage a mano o usar una biblioteca?

Escríbelo a mano cuando necesites una sola primitiva en el cliente. Recurre a una biblioteca mantenida cuando necesites manejar conjuntamente los casos extremos de serialización, SSR y sincronización entre pestañas. Ambas opciones a continuación funcionan en React 18 y 19. La línea de versiones actual es React 19.2, que se lanzó el 1 de octubre de 2025, con las versiones de parche 19.2.x desde entonces listadas en el changelog de React.

OpciónIdeal paraManejo de SSRNotas
Hook escrito a manoPrimitivas puntuales, control totalGuardia typeof window + efecto post-montajeTú gestionas los casos extremos
usehooks-tsHook directo con removeValueinitializeWithValue: falseBasado en useState + eventos, no en useSyncExternalStore
use-local-storage-stateCorrección entre pestañas y en modo concurrenteBasado en useSyncExternalStoreAmpliamente usado; el mantenedor señala que los componentes en hidratación pueden renderizarse dos veces

Aquí está el hook completo escrito a mano, correcto en React 18 y 19, con inicialización diferida, JSON con try/catch, guardia para SSR, actualizaciones funcionales, removeValue y eventos entre pestañas y dentro de la misma pestaña:

import { useCallback, useEffect, useState } from 'react';

const IS_SERVER = typeof window === 'undefined';

type Options<T> = {
  serializer?: (value: T) => string;
  deserializer?: (value: string) => T;
  initializeWithValue?: boolean; // establecer en false para SSR
};

export function useLocalStorage<T>(
  key: string,
  initialValue: T,
  options: Options<T> = {},
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  const { initializeWithValue = true } = options;
  const serialize = options.serializer ?? JSON.stringify;
  const deserialize = options.deserializer ?? ((v: string) => JSON.parse(v) as T);

  const readValue = useCallback((): T => {
    if (IS_SERVER) return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? deserialize(raw) : initialValue;
    } catch {
      return initialValue;
    }
  }, [key, initialValue, deserialize]);

  const [storedValue, setStoredValue] = useState<T>(() =>
    initializeWithValue ? readValue() : initialValue,
  );

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        const next = value instanceof Function ? value(readValue()) : value;
        window.localStorage.setItem(key, serialize(next));
        setStoredValue(next);
        window.dispatchEvent(new StorageEvent('local-storage', { key }));
      } catch {
        /* cuota excedida o modo privado — ignorar */
      }
    },
    [key, readValue, serialize],
  );

  const removeValue = useCallback(() => {
    window.localStorage.removeItem(key);
    setStoredValue(initialValue);
    window.dispatchEvent(new StorageEvent('local-storage', { key }));
  }, [key, initialValue]);

  // Sincronizar desde el almacenamiento después del montaje (corrige la hidratación SSR) y al cambiar la clave.
  useEffect(() => {
    setStoredValue(readValue());
  }, [key]); // eslint-disable-line react-hooks/exhaustive-deps

  // Listeners entre pestañas ('storage') + dentro de la misma pestaña ('local-storage').
  useEffect(() => {
    const onChange = (event: Event) => {
      const e = event as StorageEvent;
      if (e.key && e.key !== key) return;
      setStoredValue(readValue());
    };
    window.addEventListener('storage', onChange);
    window.addEventListener('local-storage', onChange);
    return () => {
      window.removeEventListener('storage', onChange);
      window.removeEventListener('local-storage', onChange);
    };
  }, [key, readValue]);

  return [storedValue, setValue, removeValue];
}

Pasa un initialValue estable (una primitiva o un objeto memoizado) para que las dependencias del efecto no se recalculen en cada renderizado.

Persistir el estado de React es una escalera, no una línea de código: comienza con un useState de inicialización diferida y un efecto de escritura, añade JSON con try/catch, protege para SSR y luego conecta los eventos entre pestañas. Añade el hook anterior a un archivo compartido en hooks/, reemplaza useState con él para la parte del estado que necesita sobrevivir a una recarga, y recurre a useSyncExternalStore o a una biblioteca mantenida en el momento en que la corrección concurrente entre pestañas empiece a importar.

Preguntas frecuentes

¿Cuál es la diferencia entre localStorage y sessionStorage para persistir el estado de React?

Ambos son almacenes de clave/valor sincrónicos, del mismo origen y solo de cadenas de texto, de aproximadamente 5MB, pero difieren en su ciclo de vida. localStorage persiste indefinidamente hasta que se borra explícitamente, por lo que el estado sobrevive a una recarga, al cierre de la pestaña y al reinicio del navegador. sessionStorage está limitado a la sesión de una sola pestaña y se borra cuando esa pestaña se cierra; además, no se comparte entre pestañas. Usa localStorage para preferencias que deban sobrevivir a la sesión y sessionStorage para estado transitorio por pestaña.

¿Por qué no debería usar Redux Persist o un almacén global para persistir un único fragmento de estado?

Recurrir a un almacén global como Redux Persist para guardar un solo valor añade un store, middleware y configuración de serialización para un estado que un hook local ya maneja. Un hook useLocalStorage mantiene el valor coubicado con el componente que lo posee e imita la ergonomía de useState, incluyendo las actualizaciones funcionales. Redux Persist justifica su peso cuando ya usas un store de Redux y necesitas la rehidratación de slices completos, no para un selector de tema o un único campo de formulario.

¿Qué ocurre cuando localStorage está lleno o deshabilitado en el modo de navegación privada?

Escribir en localStorage lanza un QuotaExceededError cuando se supera la cuota de origen de aproximadamente 5MB, y algunos navegadores lanzan una excepción en cualquier escritura en modo privado o incógnito porque la cuota está establecida en cero. Un setItem sin protección rompe el componente, que es por qué el setter en un hook robusto envuelve las escrituras en try/catch. Las lecturas también deben recurrir al valor predeterminado para que un almacén bloqueado o lleno degrade a estado en memoria en lugar de romper el renderizado.

¿useSyncExternalStore reemplaza completamente el patrón de localStorage con useState más useEffect?

No en todos los casos. useSyncExternalStore, añadido en React 18, es la forma segura para la concurrencia de suscribir un componente a un almacén mutable externo y es la elección correcta cuando importan la corrección entre pestañas y el renderizado concurrente. La propia documentación de React recomienda el estado integrado cuando sea posible y reserva el hook para integrar almacenes que no son de React. Para una sola primitiva solo en el cliente, un useState con inicialización diferida y un efecto de escritura sigue siendo más simple y correcto; adopta useSyncExternalStore cuando las pestañas deban mantenerse sincronizadas.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.