useLocalStorage
Persists state to localStorage as JSON and keeps it in sync across tabs.
Every storage access is wrapped in try/catch, so a full, disabled, or unavailable store never throws.
Install
$
npx shadcn@latest add @vibra/use-local-storageNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useLocalStorage } from "@/hooks/use-local-storage"
export default function UseLocalStorageDemo() {
const [compact, setCompact] = useLocalStorage("vibra-demo-compact-mode", false)
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>Persisted to localStorage — survives a refresh.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-2">
<Label htmlFor="use-local-storage-demo-compact">Compact mode</Label>
<Switch id="use-local-storage-demo-compact" checked={compact} onCheckedChange={setCompact} />
</div>
</CardContent>
</Card>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| key | string | — | The localStorage key to read and write. |
| initialValue | T | — | Used when storage is empty, unavailable, or holds invalid JSON. |
| returns | [T, (value: T | ((prev: T) => T)) => void] | — | Current value and a setter — the same shape as useState, but persisted. |
Dependencies
Registry
Source
import * as React from "react"
/** Reads and parses `key` from localStorage, falling back to `initialValue` when absent, unavailable, or invalid. */
function readStoredValue<T>(key: string, initialValue: T): T {
if (typeof window === "undefined") return initialValue
try {
const item = window.localStorage.getItem(key)
return item === null ? initialValue : (JSON.parse(item) as T)
} catch {
return initialValue
}
}
/** Parses a `storage` event's `newValue`, falling back to `initialValue` when null or invalid JSON. */
function parseEventValue<T>(newValue: string | null, initialValue: T): T {
if (newValue === null) return initialValue
try {
return JSON.parse(newValue) as T
} catch {
return initialValue
}
}
// Hydration-safe by construction: `useSyncExternalStore`'s snapshot only starts reading real `localStorage`
// once this hook has actually subscribed — i.e. once the render React uses to reconcile against
// server-rendered HTML has already committed — so both the server and the client's first render agree on
// `initialValue`, and the real stored value (if any) is revealed right after mount. This intentionally
// avoids a `useEffect` that calls `setState` directly (the same synchronous-setState-in-an-effect pattern
// `useMediaQuery` avoids): `subscribe`/`getSnapshot` are plain `useSyncExternalStore` callbacks, not a user
// effect, so the render-purity lint rules that pattern trips don't apply here either.
/** Persists a value to `localStorage` as JSON under `key`, synced across tabs via the "storage" event; every access is wrapped in try/catch, so it never throws. */
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Flips true the first time `subscribe` runs (safely post-mount). Until
// then `getSnapshot` returns `initialValue`, matching `getServerSnapshot`.
const revealedRef = React.useRef(false)
// The key `overrideRef` is currently cached for. Checked against the
// current `key` on every read (not just once at mount) — a mismatch is
// what catches `key` changing across re-renders without an unmount; the
// previous round only reseeded on the very first `subscribe` call, so a
// later key change left the old key's cached value in place.
const seededKeyRef = React.useRef<string | null>(null)
// An optimistic local value that wins over a fresh `localStorage` read —
// set by this hook's own setter (so a failed `setItem` still updates what
// the caller sees) and by an incoming `storage` event (so a state update
// doesn't have to wait on a redundant re-read of `localStorage`, which
// wouldn't work in tests anyway: a synthetic `StorageEvent` carries its
// `newValue` without actually touching the underlying store).
const overrideRef = React.useRef<{ value: T } | null>(null)
const onStoreChangeRef = React.useRef<() => void>(() => {})
// Re-reads storage and re-caches into `overrideRef` only when `key`
// doesn't match what's currently cached — a no-op (and therefore
// referentially stable) for repeated calls with an unchanged key, but an
// immediate, synchronous resync the moment `key` itself changes, so a
// render never shows the previous key's value under the new key. Always
// leaves `overrideRef.current` non-null.
const syncCache = React.useCallback((): T => {
if (seededKeyRef.current !== key) {
overrideRef.current = { value: readStoredValue(key, initialValue) }
seededKeyRef.current = key
}
return overrideRef.current!.value
}, [key, initialValue])
const subscribe = React.useCallback(
(onStoreChange: () => void) => {
onStoreChangeRef.current = onStoreChange
if (typeof window === "undefined") return () => {}
// Covers both "never revealed yet" (first mount) and "key changed
// since the last seed" (re-subscribing after a key change — `key` is
// in `subscribe`'s own deps, so React calls this again when it
// changes, which is the right hook point to catch up).
if (!revealedRef.current || seededKeyRef.current !== key) {
revealedRef.current = true
syncCache()
onStoreChange() // reveal the value just (re)cached above
}
const onStorage = (event: StorageEvent) => {
if (event.key !== key) return
overrideRef.current = { value: parseEventValue(event.newValue, initialValue) }
seededKeyRef.current = key
onStoreChange()
}
window.addEventListener("storage", onStorage)
return () => {
onStoreChangeRef.current = () => {}
window.removeEventListener("storage", onStorage)
}
},
[key, initialValue, syncCache]
)
const getSnapshot = React.useCallback((): T => {
if (!revealedRef.current) return initialValue
return syncCache()
}, [initialValue, syncCache])
const getServerSnapshot = React.useCallback(() => initialValue, [initialValue])
const value = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
const setStoredValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
const prev = syncCache() // never a stale key's value, even right after a key change
const resolved = next instanceof Function ? next(prev) : next
overrideRef.current = { value: resolved }
seededKeyRef.current = key
try {
if (typeof window !== "undefined") window.localStorage.setItem(key, JSON.stringify(resolved))
} catch {
// Quota exceeded, storage disabled, or unavailable — `overrideRef`
// above still reflects the optimistic value, it just won't persist.
}
onStoreChangeRef.current()
},
[key, syncCache]
)
return [value, setStoredValue]
}