# useLocalStorage

A hook that persists state to localStorage and syncs it across tabs.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-local-storage.json
```

[Registry JSON](https://mwui.vercel.app/r/use-local-storage.json)

## Preview

```tsx
import { Button } from "@/components/ui/button";

import { useLocalStorage } from "@/hooks/use-local-storage";

export function Preview() {
  const [count, setCount, reset] = useLocalStorage("mwui:preview:count", 0);

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex items-center justify-center gap-3">
        <Button size="icon-xs" variant="outline" onClick={() => setCount((value) => value - 1)}>
          -
        </Button>
        <span className="w-10 text-center text-lg tabular-nums">{count}</span>
        <Button size="icon-xs" variant="outline" onClick={() => setCount((value) => value + 1)}>
          +
        </Button>
      </div>

      <Button size="xs" variant="ghost" onClick={reset}>
        Reset
      </Button>

      <p className="text-center text-xs text-muted-foreground">
        Reload the page, or open this page in a second tab, and the value follows.
      </p>
    </div>
  );
}
```


## Source

### hooks/use-local-storage.ts

```ts
"use client";

import * as React from "react";

type LocalStorageUpdater<T> = (previous: T) => T;

type UseLocalStorageOptions<T> = {
  serialize?: (value: T) => string;
  deserialize?: (raw: string) => T;
};

function isLocalStorageUpdater<T>(
  next: T | LocalStorageUpdater<T>,
): next is LocalStorageUpdater<T> {
  return typeof next === "function";
}

function getChangeEventName(key: string) {
  return `use-local-storage:${key}`;
}

function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  { serialize = JSON.stringify, deserialize = JSON.parse }: UseLocalStorageOptions<T> = {},
): [T, (next: T | LocalStorageUpdater<T>) => void, () => void] {
  const subscribe = React.useCallback(
    (onStoreChange: () => void) => {
      const eventName = getChangeEventName(key);

      const handleStorage = (event: StorageEvent) => {
        if (event.key === null || event.key === key) onStoreChange();
      };

      window.addEventListener("storage", handleStorage);
      window.addEventListener(eventName, onStoreChange);

      return () => {
        window.removeEventListener("storage", handleStorage);
        window.removeEventListener(eventName, onStoreChange);
      };
    },
    [key],
  );

  const getSnapshot = React.useCallback(() => {
    try {
      return window.localStorage.getItem(key);
    } catch {
      return null;
    }
  }, [key]);

  const raw = React.useSyncExternalStore(subscribe, getSnapshot, () => null);

  const value = React.useMemo(() => {
    if (raw === null) return defaultValue;

    try {
      return deserialize(raw);
    } catch {
      return defaultValue;
    }
  }, [raw, defaultValue, deserialize]);

  const valueRef = React.useRef(value);
  const serializeRef = React.useRef(serialize);
  const defaultValueRef = React.useRef(defaultValue);

  React.useEffect(() => {
    valueRef.current = value;
    serializeRef.current = serialize;
    defaultValueRef.current = defaultValue;
  });

  const setValue = React.useCallback(
    (next: T | LocalStorageUpdater<T>) => {
      const nextValue = isLocalStorageUpdater(next) ? next(valueRef.current) : next;

      try {
        window.localStorage.setItem(key, serializeRef.current(nextValue));
      } catch {
        return;
      }

      valueRef.current = nextValue;
      window.dispatchEvent(new Event(getChangeEventName(key)));
    },
    [key],
  );

  const remove = React.useCallback(() => {
    try {
      window.localStorage.removeItem(key);
    } catch {
      return;
    }

    valueRef.current = defaultValueRef.current;
    window.dispatchEvent(new Event(getChangeEventName(key)));
  }, [key]);

  return [value, setValue, remove];
}

export { useLocalStorage, type LocalStorageUpdater, type UseLocalStorageOptions };
```



## Usage

State that survives a reload, shaped like `useState`.

```tsx
import { useLocalStorage } from "@/hooks/use-local-storage";

function SidebarToggle() {
  const [collapsed, setCollapsed, reset] = useLocalStorage("sidebar:collapsed", false);

  return <button onClick={() => setCollapsed((previous) => !previous)}>{String(collapsed)}</button>;
}
```

The tuple is `[value, setValue, remove]`. `setValue` takes a value or an updater; `remove` deletes
the key and returns the hook to `defaultValue`.

## Staying in sync

Every component reading the same key stays in sync, in two directions:

- **Across tabs**, through the native `storage` event.
- **Within a tab**, through a scoped event the hook dispatches on write — because `storage` does
  not fire in the tab that made the change.

So two sidebars, two tabs, and a background write all converge on the same value without a store.

## Server rendering

The hook reads through `useSyncExternalStore` with a server snapshot of `null`, so the first render
returns `defaultValue` on both sides and hydration never mismatches. The stored value appears
immediately after mount.

## Notes

- Values are serialized with `JSON.stringify` / `JSON.parse`. Pass `serialize` and `deserialize` for
  anything else, such as `Date` or `Map`.
- Unreadable, unwritable, and unparseable storage all fall back to `defaultValue` rather than
  throwing, which covers private browsing modes, disabled storage, quota limits, and values written
  by an older version of your app.
- `defaultValue` and `deserialize` participate in memoization. Hoist them to module scope, or
  memoize them, if you pass non-primitives.

