Skip to contentVibraUI
Foundation

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-storage

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
keystring—The localStorage key to read and write.
initialValueT—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

hooks/use-local-storage.ts
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]
}