10LOC
Reactintermediate

A tiny useLocalStorage hook

Published July 27, 2027

import { useState, useEffect } from "react";

export const useLocalStorage = <T>(key: string, initialValue: T) => {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored === null ? initialValue : (JSON.parse(stored) as T);
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
};

What

useLocalStorage(key, initialValue) behaves like useState, except the value is read from localStorage on mount and written back on every change — a [value, setValue] tuple backed by a browser storage key instead of purely in-memory state.

Why it matters

localStorage is a synchronous, string-only key-value API with no React integration of its own: every read and write has to be JSON-serialized by hand, and nothing about it triggers a re-render when a value changes. Wrapping it once behind a hook shaped like useState means call sites don't think about localStorage at all — they get a normal-looking piece of state that happens to survive a reload.

How it works

  • useState's lazy initializer — the function passed to useState — runs exactly once, on mount, so localStorage.getItem is read a single time, not on every render.
  • Storage is string-only, so values round-trip through JSON.parse/JSON.stringify. Anything stored has to survive that round trip — Date objects, Maps, and functions won't come back as themselves.
  • The useEffect keeps localStorage as a mirror of state, not the other way around: setValue updates React state synchronously, and the storage write happens after, as a side effect of the value changing.
  • Returning [value, setValue] as const preserves the tuple's exact positions and types, instead of widening it to (T | Dispatch<SetStateAction<T>>)[].

Gotchas

  • This hook only reads localStorage once, on mount — a change made in another tab, or by another component instance using the same key, won't be picked up here. The native storage event only fires in tabs other than the one that made the change, so catching it needs its own listener layered on top.
  • localStorage doesn't exist during server-side rendering. This hook has to run in a component that only renders on the client, or the lazy initializer throws when it calls localStorage.getItem on the server.
  • JSON.stringify silently drops values it can't represent, like undefined and functions. If initialValue (or any later value) can be undefined, storing it writes nothing meaningful — store a sentinel value instead, or narrow T to exclude it.

Related

  • useSyncExternalStore — the correct way to add the cross-tab storage event sync mentioned above without risking a tear between multiple components reading the same key.
  • Plain useState — this hook is a thin wrapper around it; reach for useState first and only add this once a value actually needs to survive a reload.