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?"
// β 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.
// 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.
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).
// 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.
// 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.
// 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
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."