React|Custom Hooks

useLocalStorage: A Browser Storage Hook Safe for Next.js (SSR)

6
useLocalStorage: A Browser Storage Hook Safe for Next.js (SSR)

A 'Dark Mode' feature has been added to the website Chris created.

Once a user enables dark mode, the setting should persist even if they visit again later. Chris naturally thought of the browser's localStorage.

"To make it persist even after a refresh, I just need to put localStorage in the useState initial value, right?"

typescript
// ❌ Chris's Code (Explodes in Next.js)
function ThemeToggle() {
  // πŸ’₯ Error: window is not defined
  const [theme, setTheme] = useState(
    localStorage.getItem('theme') || 'light'
  );

  const toggle = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return <button onClick={toggle}>{theme} Mode</button>;
}

The code that ran fine in local environments (CRA, Vite) caused the server to crash with errors as soon as it was deployed to Next.js.

Since Next.js renders the page on the server first (SSR), attempting to access window or localStorage, which do not exist on the server, triggers an error.

Besides this, there are other concerns like JSON parsing error handling and synchronization between tabs. Let's create a flawless useLocalStorage hook that solves all of these.

1. SSR Safety Mechanism: Window Check

The first thing to do is to prevent access to localStorage when the code is running on the server.

typescript
// utils.ts
export function getStorageValue<T>(key: string, initialValue: T): T {
  // 1. If Server Environment (SSR), return initial value
  if (typeof window === 'undefined') {
    return initialValue;
  }

  try {
    // 2. If Client, query local storage
    const item = window.localStorage.getItem(key);
    return item ? JSON.parse(item) : initialValue;
  } catch (error) {
    console.warn(`Error reading localStorage key "${key}":`, error);
    return initialValue;
  }
}

Now, it safely returns initialValue on the server and the stored value in the browser.

2. Solving Hydration Mismatch

However, the above function alone is not enough.

  • Server: No window, so renders in 'light' mode -> Sends HTML.
  • Client: 'dark' is stored in localStorage -> Renders in 'dark' mode.
  • If the HTML provided by the server and the result drawn by the client differ, React throws a "Hydration Mismatch" warning. To solve this, we must update the value after the first render (Mount).

    typescript
    // useLocalStorage.ts
    import { useState, useEffect, useCallback } from 'react';
    
    export function useLocalStorage<T>(key: string, initialValue: T) {
      // 1. Initialization: Start with initialValue for now (Prevent Mismatch)
      // Or, you could use useSyncExternalStore for a more complex but robust solution.
      // Here, we choose the easier-to-understand useEffect method.
      const [storedValue, setStoredValue] = useState<T>(initialValue);
    
      // 2. After Mount: Get the real value from localStorage
      useEffect(() => {
        const item = window.localStorage.getItem(key);
        if (item) {
          setStoredValue(JSON.parse(item));
        }
      }, [key]);
    
      // 3. Setter Function: Update State and LocalStorage simultaneously
      const setValue = useCallback((value: T | ((val: T) => T)) => {
        try {
          // Support functional updates (setState(prev => prev + 1))
          const valueToStore = value instanceof Function ? value(storedValue) : value;
          
          setStoredValue(valueToStore);
          window.localStorage.setItem(key, JSON.stringify(valueToStore));
          
          // Dispatch custom event (if needed for cross-tab sync)
          window.dispatchEvent(new Event("local-storage"));
        } catch (error) {
          console.warn(`Error setting localStorage key "${key}":`, error);
        }
      }, [key, storedValue]);
    
      return [storedValue, setValue] as const;
    }

    By doing this, the first screen briefly appears as 'light' (FOUC), and then switches to 'dark' immediately after JavaScript loads. For perfect UX, this should be handled with a blocking script in layout.tsx, but at the component level, this is the best approach.

    3. Advanced: Cross-Tab Synchronization

    A user turns on 'Dark Mode' in Tab A. If they look at Tab B and it's still in 'Light Mode', the UX is broken.

    The window object fires a storage event when storage values change. Subscribing to this allows for cross-tab synchronization.

    typescript
    // useLocalStorage.ts (Add useEffect)
    useEffect(() => {
      const handleStorageChange = (e: StorageEvent) => {
        // Update only if my key changed
        if (e.key === key && e.newValue) {
          setStoredValue(JSON.parse(e.newValue));
        }
      };
    
      // Event fired when changed in OTHER tabs
      window.addEventListener('storage', handleStorageChange);
      
      // Custom event listener for changes in the SAME tab (Optional)
      window.addEventListener('local-storage', handleStorageChange as any);
    
      return () => {
        window.removeEventListener('storage', handleStorageChange);
        window.removeEventListener('local-storage', handleStorageChange as any);
      };
    }, [key]);

    4. Practical Application: Chris's Dark Mode

    Now Chris just needs to import and use the finished hook.

    typescript
    // ThemeToggle.tsx
    function ThemeToggle() {
      // No errors in Next.js anymore.
      // Perfectly synchronized even after refresh or across multiple tabs.
      const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
    
      const toggle = () => {
        setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
      };
    
      return (
        <div style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
          <h1>Current Mode: {theme}</h1>
          <button onClick={toggle}>Switch Mode</button>
        </div>
      );
    }

    Key Takeaways

  • SSR Safety: Use typeof window !== 'undefined' check to prevent errors accessing window during server rendering.
  • JSON Parsing: Since localStorage only stores strings, JSON.stringify and JSON.parse are essential. Errors during this process must be caught with try-catch.
  • Synchronization: Listening to the storage event allows real-time synchronization of data state across multiple tabs.
  • Tip: Recently, there is a trend to implement storage hooks more sophisticatedly using React 18's useSyncExternalStore (Refer to Part 6).

  • Data storage is solved. Now let's move on to UI interaction.

    When opening a modal or dropdown menu, we want it to "close when clicking outside the menu."

    It looks simple, but implementing this feature, which can be tricky without knowing ref and event bubbling, can be solved with a single hook.

    Continuing in: "useOnClickOutside: The Aesthetics of Event Delegation for Closing Modals and Dropdowns."

    πŸ”— References

  • usehooks-ts: useLocalStorage
  • MDN - Window: storage event
  • Comments (0)

    0/1000 characters
    Loading comments...