Skip to content
WANCODE
BM

useLocalStorage Hook (React)

A drop-in replacement for useState that remembers its value across reloads and stays in sync between tabs.

useLocalStorage.js
import { useEffect, useRef, useState } from "react";

/**
 * Like useState, but the value is saved in localStorage and
 * restored on the next visit. Values must be JSON-serialisable.
 */
export function useLocalStorage(key, initialValue) {
  const initialRef = useRef(initialValue);

  const [value, setValue] = useState(() => {
    try {
      const stored = window.localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      // No window (server render), storage blocked, or invalid JSON
      return initialValue;
    }
  });

  // Save whenever the value changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Storage full or disabled — keep working in memory
    }
  }, [key, value]);

  // Stay in sync when another tab changes the same key
  useEffect(() => {
    function onStorage(event) {
      if (event.storageArea !== window.localStorage || event.key !== key) return;
      try {
        setValue(event.newValue !== null ? JSON.parse(event.newValue) : initialRef.current);
      } catch {
        // Ignore values we can't parse
      }
    }
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, [key]);

  return [value, setValue];
}

A custom hook is just a function whose name starts with use and that calls other hooks. This one wraps useState and adds two side effects: save to localStorage when the value changes, and listen for changes from other tabs.

Line by line

  • useState(() => …) uses a lazy initialiser: the function runs only on the first render, so we read and parse storage once, not on every render.
  • localStorage only stores strings, so values go through JSON.stringify on the way in and JSON.parse on the way out. That's why values must be JSON-friendly (no functions, Dates become strings).
  • Every storage call is in try/catch: storage can be disabled, full (QuotaExceededError) or hold invalid JSON. The hook falls back to normal in-memory state instead of crashing your component.
  • The first useEffect runs after render whenever key or value changes, so saving never slows down the render itself.
  • The browser fires a storage event in other tabs of the same site when one tab writes. The second effect listens for it and updates state, then removes the listener in its cleanup function.
  • initialRef remembers the first initialValue, so passing a new array/object literal on every render doesn't re-subscribe the listener.

Know the limits

Don't store secrets or tokens here — any script on the page can read localStorage. With server rendering (e.g. Next.js), the server always renders initialValue; use the hook only in client components. Changing key at runtime doesn't reload the stored value — remount the component (give it a different key prop) instead.

Try it: create a React project in the Playground, paste the hook and the usage example, then press Run twice — the value survives because the preview emulates localStorage per project.

Usage

JSX
import { useLocalStorage } from "./useLocalStorage.js";

export default function App() {
  const [name, setName] = useLocalStorage("profile-name", "");
  const [fontSize, setFontSize] = useLocalStorage("reader-font-size", 16);

  return (
    <main style={{ fontSize }}>
      <label>
        Your name{" "}
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </label>
      <p>Hello, {name || "stranger"}! Reload — I still remember you.</p>
      <button type="button" onClick={() => setFontSize((s) => s + 2)}>A+</button>
      <button type="button" onClick={() => setFontSize((s) => Math.max(12, s - 2))}>A−</button>
    </main>
  );
}