useLocalStorage
Cuándo usarlo
Sección titulada «Cuándo usarlo»Para preferencias pequeñas como el tema o filtros. Requiere React 18 o superior. El servidor y la primera hidratación muestran initialValue; después se lee la preferencia guardada. Si el tema debe estar aplicado antes de pintar, resuélvelo también en la estrategia de HTML/CSS de tu aplicación.
El snapshot es el texto JSON, un valor primitivo estable. La conversión se hace fuera de getSnapshot: devolver allí un objeto nuevo en cada lectura provocaría renders repetidos.
import { useCallback, useMemo, useSyncExternalStore } from "react"
const localChange = "app:local-storage-change"const serverSnapshot = () => null
export function useLocalStorage<T>( key: string, initialValue: T, isValid: (value: unknown) => value is T) { const read = useCallback((): string | null => { try { return window.localStorage.getItem(key) } catch { return null } }, [key])
const decode = useCallback((raw: string | null): T => { if (raw === null) return initialValue try { const parsed: unknown = JSON.parse(raw) return isValid(parsed) ? parsed : initialValue } catch { return initialValue } }, [initialValue, isValid])
const subscribe = useCallback((notify: () => void) => { function onStorage(event: StorageEvent) { if (event.storageArea === window.localStorage && (event.key === key || event.key === null)) notify() } window.addEventListener("storage", onStorage) window.addEventListener(localChange, notify) return () => { window.removeEventListener("storage", onStorage) window.removeEventListener(localChange, notify) } }, [key])
const raw = useSyncExternalStore(subscribe, read, serverSnapshot) const value = useMemo(() => decode(raw), [raw, decode])
const setValue = useCallback((next: T | ((current: T) => T)) => { const resolved = typeof next === "function" ? (next as (current: T) => T)(decode(read())) : next const serialized = JSON.stringify(resolved) if (serialized === undefined || !isValid(JSON.parse(serialized))) { throw new TypeError("El valor debe conservar su tipo al serializarse a JSON") } // Si la escritura falla, el llamador recibe el error; no fingimos persistencia. window.localStorage.setItem(key, serialized) window.dispatchEvent(new Event(localChange)) }, [key, read, decode, isValid])
const remove = useCallback(() => { window.localStorage.removeItem(key) window.dispatchEvent(new Event(localChange)) }, [key])
return [value, setValue, remove] as const}import { useState } from "react"import { useLocalStorage } from "./hooks/useLocalStorage"
type Tema = "claro" | "oscuro"const esTema = (value: unknown): value is Tema => value === "claro" || value === "oscuro"
export function PreferenciaTema() { const [tema, setTema, restablecer] = useLocalStorage<Tema>("tema", "claro", esTema) const [error, setError] = useState("")
function guardar(action: () => void) { try { action() setError("") } catch { setError("No se pudo guardar la preferencia en este navegador.") } }
return ( <section aria-label="Preferencia de tema"> <p>Tema: {tema}</p> <button onClick={() => guardar(() => setTema(actual => actual === "claro" ? "oscuro" : "claro") )}>Cambiar tema</button> <button onClick={() => guardar(restablecer)}>Restablecer</button> <p role="status">{error}</p> </section> )}En Next.js, coloca "use client" en el archivo del componente que establece la frontera cliente. En Astro, hidrata el componente con una directiva como client:load.
Comprobación
Sección titulada «Comprobación»- Guarda
"oscuro"bajo la clavetemay recarga una página con SSR: no debe haber advertencias de hidratación. - Monta dos instancias del componente: cambiar una actualiza ambas. Abre otra pestaña del mismo origen y comprueba la sincronización.
- Restablece la clave, y prueba
localStorage.clear()desde otra pestaña: vuelve a"claro". - Guarda JSON inválido o un número: se usa el valor inicial. Bloquea el almacenamiento: el botón muestra un error.
Límites y decisiones
Sección titulada «Límites y decisiones»- Usa datos representables en JSON y valida también su estructura interna. Un genérico de TypeScript no valida lo que otro script escribió.
- Comparte el mismo valor inicial y validador para una misma clave. Para objetos, decláralos fuera del componente o estabiliza su identidad.
- Los cambios de esta API notifican a la misma pestaña. Una escritura directa con
localStorage.setItemen esa pestaña debe emitir también el evento o usar esta API. - No hay transacciones entre pestañas: dos actualizaciones simultáneas pueden sobrescribirse. El último guardado prevalece.
- La lectura fallida usa el valor inicial; la escritura fallida lanza un error. No hay respaldo en memoria ni garantía de persistencia.
- No almacenes contraseñas ni tokens de sesión aquí. El almacenamiento es síncrono y accesible a JavaScript del mismo origen.