Skip to the code
cf.

useLocalStorage

Write useLocalStorage(key, initialValue) and export it from useLocalStorage.js. It works like useState, returning [value, setValue], but the value is saved in localStorage under key, so the tea shop's cart is still there after a reload.

Read localStorage once, when the component first renders, and parse what's saved with JSON.parse. If nothing is saved, start with initialValue. If what's saved isn't valid JSON (someone edited it by hand), don't crash: start with initialValue.

setValue(next) sets the value and saves JSON.stringify(next) under key. Like a state setter, it also takes an updater, setValue((current) => next), and two updates in the same event both apply.

Other tabs share the same storage. When another tab changes it, the browser fires a storage event on window with the key that changed and its newValue: a string, or null when the item was removed. When the event's key is yours, take the new value. When the item was removed, or the event's key is null (the other tab cleared all of storage), go back to initialValue. Ignore events for other keys, and stop listening when the component goes away.

You can assume key stays the same for as long as the component is on the page.

function Cart() {
  const [teas, setTeas] = useLocalStorage("cart", []);
  return (
    <div>
      <p>{teas.length} in the cart</p>
      <button onClick={() => setTeas((current) => [...current, "Sencha"])}>Add Sencha</button>
    </div>
  );
}

The Preview shows Demo.jsx, a cart that uses your hook. Reload the Preview and the cart is still there.

What the tests check

  1. starts with initialValue when nothing is saved
    const { result } = renderHook(() => useLocalStorage("cart", []));
    expect(result.current[0]).toEqual([]);
    
  2. starts with the saved value
    localStorage.setItem("cart", '["Sencha","Oolong"]');
    const { result } = renderHook(() => useLocalStorage("cart", []));
    expect(result.current[0]).toEqual(["Sencha", "Oolong"]);
    
  3. setValue changes the value and saves it as JSON
    const { result } = renderHook(() => useLocalStorage("cart", []));
    act(() => result.current[1](["Assam"]));
    expect(result.current[0]).toEqual(["Assam"]);
    expect(localStorage.getItem("cart")).toBe('["Assam"]');
    
  4. setValue takes an updater, and two in one event both apply
    const { result } = renderHook(() => useLocalStorage("cups", 0));
    const setCups = result.current[1];
    act(() => {
      setCups((cups) => cups + 1);
      setCups((cups) => cups + 1);
    });
    expect(result.current[0]).toBe(2);
    expect(localStorage.getItem("cups")).toBe("2");
    
  5. starts with initialValue when the saved value isn't JSON
    localStorage.setItem("cart", "{oops");
    const { result } = renderHook(() => useLocalStorage("cart", []));
    expect(result.current[0]).toEqual([]);
    
  6. reads localStorage only once
    const read = localStorage.getItem;
    let reads = 0;
    localStorage.getItem = (key) => {
      if (key === "cart") reads++;
      return read(key);
    };
    const { result, rerender } = renderHook(() => useLocalStorage("cart", []));
    rerender();
    rerender();
    act(() => result.current[1](["Sencha"]));
    expect(reads).toBe(1);
    
  7. the cart is still there when the component comes back
    function Cart() {
      const [teas, setTeas] = useLocalStorage("cart", []);
      return (
        <div>
          <p>{teas.length} in the cart</p>
          <button onClick={() => setTeas((current) => [...current, "Sencha"])}>Add Sencha</button>
        </div>
      );
    }
    const first = render(<Cart />);
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    first.unmount();
    render(<Cart />);
    expect(screen.getByText("2 in the cart")).toBeInTheDocument();
    
  8. takes another tab's change to its key, and ignores other keys
    const { result } = renderHook(() => useLocalStorage("cart", []));
    act(() => {
      window.dispatchEvent(new StorageEvent("storage", { key: "cart", newValue: '["Gyokuro"]' }));
    });
    expect(result.current[0]).toEqual(["Gyokuro"]);
    act(() => {
      window.dispatchEvent(new StorageEvent("storage", { key: "theme", newValue: '"dark"' }));
    });
    expect(result.current[0]).toEqual(["Gyokuro"]);
    
  9. goes back to initialValue when another tab removes it or clears storage
    localStorage.setItem("cart", '["Sencha"]');
    const { result } = renderHook(() => useLocalStorage("cart", []));
    act(() => {
      window.dispatchEvent(new StorageEvent("storage", { key: "cart", newValue: null }));
    });
    expect(result.current[0]).toEqual([]);
    act(() => {
      window.dispatchEvent(new StorageEvent("storage", { key: "cart", newValue: '["Assam"]' }));
    });
    act(() => {
      window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }));
    });
    expect(result.current[0]).toEqual([]);
    
  10. stops listening for storage events when it goes away
    const listening = new Set();
    const add = window.addEventListener;
    const remove = window.removeEventListener;
    window.addEventListener = function (type, listener, options) {
      if (type === "storage") listening.add(listener);
      return add.call(this, type, listener, options);
    };
    window.removeEventListener = function (type, listener, options) {
      if (type === "storage") listening.delete(listener);
      return remove.call(this, type, listener, options);
    };
    const { unmount } = renderHook(() => useLocalStorage("cart", []));
    expect(listening.size).toBeGreaterThan(0);
    unmount();
    expect(listening.size).toBe(0);
    

Learn more

useLocalStorage.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).