useLocalStorage — useState respaldado por localStorage con sincronización entre pestañas
Receta
import { useState, useEffect, useCallback } from "react";
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
// El inicializador lazy lee del almacenamiento solo una vez
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
// Persiste en localStorage cuando el valor o la clave cambian
useEffect(() => {
if (typeof window === "undefined") return;
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
// Cuota de almacenamiento excedida o no disponible
}
}, [key, storedValue]);
// Sincroniza entre pestañas a través del evento storage
useEffect(() => {
if (typeof window === "undefined") return;
const handleStorage = (e: StorageEvent) => {
if (e.key !== key) return;
if (e.newValue === null) {
setStoredValue(initialValue);
} else {
try {
setStoredValue(JSON.parse(e.newValue) as T);
} catch {
// Ignora JSON malformado
}
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [key, initialValue]);
// Setter que coincide con la firma de useState (valor o fn actualizadora)
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const nextValue =
value instanceof Function ? value(prev) : value;
return nextValue;
});
},
[]
);
// Elimina la clave del almacenamiento y restablece a inicial
const remove = useCallback(() => {
if (typeof window !== "undefined") {
localStorage.removeItem(key);
}
setStoredValue(initialValue);
}, [key, initialValue]);
return [storedValue, setValue, remove];
}Cuándo usarlo: Deseas que el estado del componente sobreviva a las actualizaciones de página, y opcionalmente se mantenga sincronizado en las pestañas del navegador, sin necesidad de una biblioteca externa.
Ejemplo funcional
"use client";
function ThemeToggle() {
const [theme, setTheme, removeTheme] = useLocalStorage<"light" | "dark">(
"app-theme",
"light"
);
return (
<div>
<p>Tema actual: {theme}</p>
<button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
Alternar tema
</button>
<button onClick={removeTheme}>Restablecer predeterminado</button>
</div>
);
}
function FormDraft() {
const [draft, setDraft] = useLocalStorage("form-draft", {
name: "",
email: "",
});
return (
<form>
<input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="Name"
/>
<input
value={draft.email}
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
placeholder="Email"
/>
<p>Borrador guardado automáticamente en localStorage</p>
</form>
);
}Lo que esto demuestra:
- Almacenamiento seguro de tipos con tipo de unión
"light" | "dark" - Sintaxis de función actualizadora
(prev) => nextcomo enuseState - Almacenamiento de objetos con serialización JSON
- Función
removepara borrar la clave y restablecer el estado - Abrir dos pestañas muestra el tema sincronizándose a través del evento
storage
Análisis profundo
Cómo funciona
- Inicializador lazy: La callback de
useStatelee delocalStoragesolo en el primer renderizado, evitando lecturas redundantes en cada re-renderizado. - Protección de SSR: Cada acceso a
windowestá protegido por verificacionestypeof window === "undefined", de modo que el hook devuelveinitialValuedurante la renderización del lado del servidor sin desincronización de hidratación. - Ciclo JSON: Los valores se serializan con
JSON.stringifyy se deserializan conJSON.parse. Esto maneja primitivos, arrays y objetos simples. - Sincronización entre pestañas: El evento
storagese dispara en otras pestañas cuando la misma clave cambia. El listener actualiza el estado local para que coincida. - Actualizaciones funcionales: El setter acepta un valor directo o una función
(prev) => next, reflejando la API deuseState.
Parámetros y valores de retorno
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
key | string | — | La clave de localStorage |
initialValue | T | — | Fallback cuando la clave falta o en SSR |
| Índice de retorno | Tipo | Descripción |
|---|---|---|
[0] | T | Valor actual |
[1] | (value: T or ((prev: T) => T)) => void | Setter (valor o función actualizadora) |
[2] | () => void | Elimina la clave y restablece a inicial |
Variaciones
Con expiración: Añade un TTL almacenando { value, expiresAt } y verificando al leer:
const item = JSON.parse(raw);
if (item.expiresAt && Date.now() > item.expiresAt) {
localStorage.removeItem(key);
return initialValue;
}
return item.value;Con serializador personalizado: Acepta opciones serialize y deserialize para datos no JSON (por ejemplo, superjson para objetos Date, Map, Set).
Variante de sessionStorage: Cambia localStorage por sessionStorage — la API es idéntica, pero los datos se borran cuando se cierra la pestaña.
Notas de TypeScript
- El genérico
Tfluye desdeinitialValueal estado almacenado y al setter, proporcionando inferencia de tipo completa. - Los tipos de unión como
"light" | "dark"estrechan la entrada del setter automáticamente. - Para tipos complejos, pasa el genérico explícitamente:
useLocalStorage<User>("user", defaultUser).
Puntos débiles
- Valores no serializables — Las funciones, Symbols,
undefined, y objetos circulares no pueden sobrevivir aJSON.stringify. Solución: Solo almacena datos serializables simples. Usasuperjsonpara Date, Map, Set. - Cuota de almacenamiento excedida — Los navegadores limitan localStorage a alrededor de 5 MB. Solución: El try/catch en el efecto de escritura lo maneja silenciosamente; considera advertir al usuario.
- El evento storage solo se dispara en otras pestañas — La misma pestaña que escribió el valor no recibe un evento
storage. Solución: Este hook maneja las actualizaciones de la misma pestaña a través desetState; la sincronización entre pestañas se maneja mediante el event listener. - Colisiones de claves — Múltiples aplicaciones en el mismo origen comparten localStorage. Solución: Prefija las claves con un espacio de nombres de la aplicación como
myapp:theme. - Desincronización de hidratación — Si el servidor renderiza con
initialValuepero el cliente lee un valor diferente del almacenamiento, ocurre una desincronización. Solución: El inicializador lazy solo se ejecuta en el cliente. Para frameworks SSR, el renderizado inicial usainitialValue, luego se actualiza después de la hidratación.
Alternativas
| Paquete | Nombre del hook | Notas |
|---|---|---|
usehooks-ts | useLocalStorage | Popular, API similar |
@uidotdev/usehooks | useLocalStorage | Mínimal, bien probado |
ahooks | useLocalStorageState | Soporta serializador personalizado |
jotai | atomWithStorage | Persistencia basada en átomo |
zustand | persist middleware | Persistencia a nivel de almacén |
Preguntas frecuentes
¿Cómo funciona la sincronización entre pestañas en este hook?
- El evento
storagese dispara en otras pestañas cuando la misma clave se escribe en localStorage. - El hook escucha este evento y actualiza el estado local cuando detecta un cambio en su clave.
- La misma pestaña que escribió el valor no recibe el evento; se actualiza a través de
setStatedirectamente.
¿Por qué el hook usa un inicializador lazy en useState en lugar de leer localStorage en el cuerpo de la función?
El inicializador lazy (forma de callback de useState) se ejecuta solo en el primer renderizado. Leer localStorage en cada renderizado sería ineficaz ya que el valor almacenado solo necesita leerse una vez al montar.
¿Qué tipos de valores no se pueden almacenar con este hook?
- Las funciones y Symbols se descartan silenciosamente por
JSON.stringify. undefinedtambién se descarta (se convierte ennull).- Las referencias a objetos circulares lanzan un error.
Date,Map, ySetpierden sus tipos (se convierten en strings/arrays). Usasuperjsonpara estos.
¿Qué sucede cuando localStorage está lleno?
Los navegadores limitan localStorage a alrededor de 5 MB por origen. Cuando se alcanza el límite, setItem lanza una excepción. El hook envuelve la escritura en un try/catch para que la aplicación no se bloquee, pero los datos no se persisten silenciosamente.
¿Cómo evito colisiones de claves con otras aplicaciones en el mismo dominio?
Prefija tus claves con un espacio de nombres específico de la aplicación:
const [theme, setTheme] = useLocalStorage("myapp:theme", "light");Punto débil: veo un destello de desincronización de hidratación cuando el valor renderizado en el servidor difiere de localStorage. ¿Por qué?
El servidor renderiza con initialValue, pero el cliente lee un valor diferente del almacenamiento al montar. El inicializador lazy se ejecuta solo en el cliente, causando una breve desincronización. Esto es lo esperado; para UI crítica, considera retrasar el renderizado hasta que se complete la hidratación.
Punto débil: mi objeto almacenado pierde sus campos Date después de una actualización de página. ¿Cómo lo arreglo?
JSON.parse convierte las cadenas Date de vuelta a cadenas simples, no a objetos Date. Usa un serializador personalizado como superjson que preserve los tipos, o rehidrata manualmente las fechas después de leer.
¿Cómo el setter soporta tanto valores directos como funciones actualizadoras como useState?
La callback setValue verifica value instanceof Function. Si es verdadero, llama a la función con el estado anterior. De lo contrario, usa el valor directamente. Esto refleja la API de useState.
¿Cómo TypeScript infiere el tipo almacenado automáticamente?
El genérico T se infiere de initialValue. Por ejemplo, useLocalStorage("theme", "light") infiere T como string. Para tipos de unión, pasa el genérico explícitamente:
useLocalStorage<"light" | "dark">("theme", "light");¿Puedo usar este hook con sessionStorage en su lugar?
Sí. La API de sessionStorage es idéntica a localStorage. Cambia cada llamada localStorage por sessionStorage. La única diferencia es que los datos se borran cuando se cierra la pestaña del navegador.
Relacionado
- useToggle — combina con localStorage para conmutadores persistentes
- useMediaQuery — detecta la preferencia del tema del sistema
- useState — la primitiva subyacente