Build a Custom useLocalStorage Hook: Persistent State in React
When building React applications, you often need to preserve user data across page refreshes. Whether it's saving user preferences, form drafts, or theme settings, localStorage is the go-to browser API. But using localStorage directly in your components leads to messy, repetitive code. This is where a custom useLocalStorage hook becomes invaluable—it abstracts the complexity and gives you a clean API that feels like working with useState, but with automatic persistence.
In this guide, you'll learn how to build a production-ready useLocalStorage hook from the ground up, handle edge cases like SSR environments, manage type safety with TypeScript, and integrate it into real-world scenarios that developers face daily.
Table of Contents
- Understanding the Problem
- Building the Basic Hook
- TypeScript Implementation
- Advanced Features
- Server-Side Rendering Considerations
- Practical Application: User Preferences System
- Performance Optimization
- FAQ
Understanding the Problem
Imagine you're building a dashboard for a content management system. Users configure their dashboard layout, but without persistence, their preferences vanish after a refresh. You'd naturally reach for localStorage, but handling it properly requires managing several concerns:
- Serialization: Converting objects to JSON strings and back
- Initialization: Loading data on mount without causing hydration mismatches
- Error Handling: Dealing with quota exceeded or corrupted data
- Type Safety: Ensuring your stored data matches expected types
- Reactivity: Making sure state updates reflect immediately in the UI
Without a custom hook, you'd scatter localStorage logic across multiple components using useEffect. A dedicated hook keeps this logic centralized and reusable.
Building the Basic Hook
Let's start with a straightforward implementation that handles the core functionality:
TypeScript Version
import { useState, useEffect } from 'react';
interface UseLocalStorageOptions {
syncData?: boolean;
}
export function useLocalStorage<T>(
key: string,
initialValue: T,
options: UseLocalStorageOptions = {}
) {
const { syncData = true } = options;
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = typeof window !== 'undefined' ? window.localStorage.getItem(key) : null;
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Failed to retrieve ${key} from localStorage:`, error);
return initialValue;
}
});
// Update localStorage when state changes
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Failed to set ${key} in localStorage:`, error);
}
};
// Sync state across browser tabs
useEffect(() => {
if (!syncData || typeof window === 'undefined') return;
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.warn(`Failed to sync ${key} from storage event:`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, syncData]);
return [storedValue, setValue] as const;
}
JavaScript Version
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue, options = {}) {
const { syncData = true } = options;
const [storedValue, setStoredValue] = useState(() => {
try {
const item = typeof window !== 'undefined' ? window.localStorage.getItem(key) : null;
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Failed to retrieve ${key} from localStorage:`, error);
return initialValue;
}
});
// Update localStorage when state changes
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Failed to set ${key} in localStorage:`, error);
}
};
// Sync state across browser tabs
useEffect(() => {
if (!syncData || typeof window === 'undefined') return;
const handleStorageChange = (e) => {
if (e.key === key && e.newValue) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.warn(`Failed to sync ${key} from storage event:`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, syncData]);
return [storedValue, setValue];
}
Why This Approach Works
Lazy Initialization: The useState callback only runs once during mount, avoiding repeated localStorage reads. This is crucial for performance—you don't want to parse JSON on every render.
SSR Compatibility: The typeof window !== 'undefined' checks prevent errors in server-side rendering environments where localStorage doesn't exist. During initial render, the hook falls back to initialValue on the server.
Function Updates: Like useState, the setValue function accepts either a direct value or an updater function. This matters when your update depends on the previous state: setTheme(prev => prev === 'dark' ? 'light' : 'dark').
Tab Synchronization: The storage event fires when another tab modifies localStorage. This keeps your app in sync across browser windows—changing your theme in one tab updates it everywhere without manual refreshing.
TypeScript Implementation
For teams prioritizing type safety, here's an enhanced version with better type inference:
import { useState, useEffect, useCallback } from 'react';
interface StorageOptions<T> {
syncData?: boolean;
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
}
interface StorageReturn<T> {
value: T;
setValue: (val: T | ((prev: T) => T)) => void;
removeValue: () => void;
}
export function useLocalStorage<T>(
key: string,
initialValue: T,
options: StorageOptions<T> = {}
): StorageReturn<T> {
const {
syncData = true,
serializer = JSON.stringify,
deserializer = JSON.parse,
} = options;
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = typeof window !== 'undefined' ? window.localStorage.getItem(key) : null;
return item ? deserializer(item) : initialValue;
} catch (error) {
console.warn(`useLocalStorage init error for key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, serializer(valueToStore));
}
} catch (error) {
console.error(`useLocalStorage set error for key "${key}":`, error);
}
},
[key, storedValue, serializer]
);
const removeValue = useCallback(() => {
try {
setStoredValue(initialValue);
if (typeof window !== 'undefined') {
window.localStorage.removeItem(key);
}
} catch (error) {
console.error(`useLocalStorage remove error for key "${key}":`, error);
}
}, [key, initialValue]);
// Sync across tabs
useEffect(() => {
if (!syncData || typeof window === 'undefined') return;
const handleStorageChange = (e: StorageEvent) => {
if (e.key !== key) return;
try {
setStoredValue(e.newValue ? deserializer(e.newValue) : initialValue);
} catch (error) {
console.warn(`useLocalStorage sync error for key "${key}":`, error);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, syncData, deserializer, initialValue]);
return {
value: storedValue,
setValue,
removeValue,
};
}
TypeScript Usage
interface UserPreferences {
theme: 'light' | 'dark';
sidebarCollapsed: boolean;
language: string;
}
function Settings() {
const { value: prefs, setValue: setPrefs } = useLocalStorage<UserPreferences>(
'userPrefs',
{ theme: 'light', sidebarCollapsed: false, language: 'en' }
);
const toggleTheme = () => {
setPrefs(prev => ({
...prev,
theme: prev.theme === 'light' ? 'dark' : 'light'
}));
};
return (
<button onClick={toggleTheme}>
Switch to {prefs.theme === 'light' ? 'dark' : 'light'} mode
</button>
);
}
Notice the explicit type argument: useLocalStorage<UserPreferences>. This ensures TypeScript validates that your stored data matches the expected shape, catching errors at build time rather than runtime.
Advanced Features
Custom Serializers
Sometimes you need to handle data that JSON.stringify can't properly serialize, like Date objects or Map instances:
interface CustomSerializerOptions {
serializer?: (value: any) => string;
deserializer?: (value: string) => any;
}
// Example: Handling Dates
const dateAwareHook = useLocalStorage(
'lastLogin',
new Date(),
{
serializer: (value) => JSON.stringify({
...value,
timestamp: value.getTime?.()
}),
deserializer: (str) => new Date(JSON.parse(str).timestamp)
}
);
Merge Strategy
When localStorage data might be partially updated, implement smart merging:
export function useLocalStorageWithMerge<T extends Record<string, any>>(
key: string,
initialValue: T,
options = {}
) {
const { value, setValue } = useLocalStorage<T>(key, initialValue, options);
const setPartialValue = useCallback(
(partial: Partial<T>) => {
setValue(prev => ({ ...prev, ...partial }));
},
[setValue]
);
return [value, setValue, setPartialValue] as const;
}
// Usage
const [settings, setSettings, updateSettings] = useLocalStorageWithMerge(
'appSettings',
{ volume: 50, notifications: true, autoplay: false }
);
// Update just one property
updateSettings({ volume: 75 });
Server-Side Rendering Considerations
If you're using Next.js or another SSR framework, localStorage access must be deferred to the client. The hook already handles this with typeof window !== 'undefined', but here's the recommended pattern:
Next.js / React Server Components
'use client'; // Mark as client component
import { useLocalStorage } from '@/hooks/useLocalStorage';
export function ClientOnlyComponent() {
const { value: isDarkMode, setValue: setIsDarkMode } = useLocalStorage(
'theme',
'light'
);
return (
<button onClick={() => setIsDarkMode(isDarkMode === 'light' ? 'dark' : 'light')}>
Theme: {isDarkMode}
</button>
);
}
The 'use client' directive ensures this component only renders in the browser, preventing hydration mismatches where the server renders one thing and the client renders another.
Practical Application: User Preferences System
Let's see how useLocalStorage solves a real problem. ByteDance's content creators often work across multiple content editing tools. A dashboard that remembers each user's layout preferences dramatically improves workflow:
TypeScript Version
interface DashboardPreferences {
layout: 'grid' | 'list' | 'compact';
itemsPerPage: number;
sortBy: 'date' | 'views' | 'engagement';
filteredCategories: string[];
lastViewedPost?: number;
}
export function Dashboard() {
const defaultPrefs: DashboardPreferences = {
layout: 'grid',
itemsPerPage: 20,
sortBy: 'date',
filteredCategories: [],
};
const { value: prefs, setValue: setPrefs, removeValue } = useLocalStorage<DashboardPreferences>(
'dashboardPrefs',
defaultPrefs
);
const handleLayoutChange = (newLayout: DashboardPreferences['layout']) => {
setPrefs(prev => ({ ...prev, layout: newLayout }));
};
const handleCategoryFilter = (category: string) => {
setPrefs(prev => ({
...prev,
filteredCategories: prev.filteredCategories.includes(category)
? prev.filteredCategories.filter(c => c !== category)
: [...prev.filteredCategories, category],
}));
};
const resetToDefaults = () => {
removeValue();
};
return (
<div>
<div className="toolbar">
<select value={prefs.layout} onChange={(e) => handleLayoutChange(e.target.value as any)}>
<option value="grid">Grid View</option>
<option value="list">List View</option>
<option value="compact">Compact View</option>
</select>
<input
type="number"
value={prefs.itemsPerPage}
onChange={(e) => setPrefs(prev => ({ ...prev, itemsPerPage: parseInt(e.target.value) }))}
min="5"
max="100"
/>
<button onClick={resetToDefaults}>Reset Preferences</button>
</div>
{/* Render content based on prefs */}
</div>
);
}
JavaScript Version
export function Dashboard() {
const defaultPrefs = {
layout: 'grid',
itemsPerPage: 20,
sortBy: 'date',
filteredCategories: [],
};
const { value: prefs, setValue: setPrefs, removeValue } = useLocalStorage(
'dashboardPrefs',
defaultPrefs
);
const handleLayoutChange = (newLayout) => {
setPrefs(prev => ({ ...prev, layout: newLayout }));
};
const handleCategoryFilter = (category) => {
setPrefs(prev => ({
...prev,
filteredCategories: prev.filteredCategories.includes(category)
? prev.filteredCategories.filter(c => c !== category)
: [...prev.filteredCategories, category],
}));
};
const resetToDefaults = () => {
removeValue();
};
return (
<div>
<div className="toolbar">
<select value={prefs.layout} onChange={(e) => handleLayoutChange(e.target.value)}>
<option value="grid">Grid View</option>
<option value="list">List View</option>
<option value="compact">Compact View</option>
</select>
<input
type="number"
value={prefs.itemsPerPage}
onChange={(e) => setPrefs(prev => ({ ...prev, itemsPerPage: parseInt(e.target.value) }))}
min="5"
max="100"
/>
<button onClick={resetToDefaults}>Reset Preferences</button>
</div>
</div>
);
}
Why This Matters in Practice: Content creators at companies like ByteDance often switch between viewing analytics, drafting new content, and reviewing comments. Their preferred layout, pagination settings, and filters change throughout the day. Without persistence, they'd repeatedly reconfigure these preferences. Our custom hook ensures their preferences survive page refreshes, browser restarts, and tab switches—all transparently, with zero additional effort in the component.
Performance Optimization
Preventing Unnecessary Re-renders
For expensive computations based on stored values, use derived state:
export function ExpensiveComponent() {
const { value: rawData } = useLocalStorage('largeDataSet', []);
// Memoize derived computations
const processedData = useMemo(
() => expensiveTransformation(rawData),
[rawData]
);
return <div>{processedData.length} items processed</div>;
}
Debouncing Updates
If your hook receives rapid updates (like form input changes), debounce localStorage writes:
export function useDebouncedLocalStorage<T>(
key: string,
initialValue: T,
debounceMs: number = 500
) {
const { value, setValue } = useLocalStorage(key, initialValue);
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setValue(debouncedValue);
}, debounceMs);
return () => clearTimeout(timer);
}, [debouncedValue, setValue]);
return [debouncedValue, setDebouncedValue] as const;
}
// Usage: Only writes to localStorage every 500ms, not on every keystroke
const [searchQuery, setSearchQuery] = useDebouncedLocalStorage('search', '', 500);
FAQ
Q: How do I handle quota exceeded errors?
A: localStorage has a size limit (typically 5-10MB per domain). Catch QuotaExceededError and implement a fallback:
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, serializer(valueToStore));
}
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
console.error('localStorage is full, clearing old data...');
// Implement cleanup strategy: remove old entries, compress data, etc.
window.localStorage.clear(); // Or implement smarter cleanup
window.localStorage.setItem(key, serializer(valueToStore));
} else {
console.error(`Failed to set ${key}:`, error);
}
}
},
[key, storedValue, serializer]
);
Q: How do I share state between multiple components without context?
A: Multiple components using useLocalStorage with the same key automatically sync through the storage event listener. This works across tabs and windows—enable cross-tab communication without Redux or Context:
// ComponentA.tsx
function ComponentA() {
const { value: sharedData } = useLocalStorage('sharedData', {});
return <div>Data: {JSON.stringify(sharedData)}</div>;
}
// ComponentB.tsx
function ComponentB() {
const { value: sharedData, setValue } = useLocalStorage('sharedData', {});
return (
<button onClick={() => setValue({ ...sharedData, updated: true })}>
Update from B
</button>
);
}
ComponentA automatically re-renders when ComponentB updates the same key. This is especially useful for theme switches and user authentication state.
Q: Should I use localStorage or sessionStorage?
A: localStorage persists until explicitly cleared; sessionStorage clears when the tab closes. Use localStorage for user preferences, authentication tokens, and draft data. Use sessionStorage for temporary UI state that shouldn't survive tab closure. The hook works with both—just swap localStorage for sessionStorage in the implementation.
Q: How do I handle sensitive data like API tokens?
A: Never store sensitive data in localStorage—it's vulnerable to XSS attacks. Use secure HttpOnly cookies for authentication tokens instead. If you must use localStorage, encrypt the data and implement rotation policies. For API keys and secrets, store them server-side only.
// ❌ BAD: Never do this
const { value: apiToken } = useLocalStorage('apiToken', '');
// ✅ GOOD: Use secure cookies with HttpOnly flag
// Server sets: Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict
Q: What about TypeScript strict mode with the [value, setValue] return type?
A: Use as const assertion to maintain tuple typing:
return [storedValue, setValue] as const;
// This preserves the exact tuple type for destructuring:
const [theme, setTheme] = useLocalStorage('theme', 'light');
// TypeScript knows theme is 'light' | 'dark', not just string
Without as const, TypeScript treats the return as a union type (string | ((prev: string) => string))[], losing type precision.
Related Articles:
- Understanding useEffect and Side Effects
- Custom Hooks for Logic Reuse
- State Management Patterns Beyond Context
- Building Hooks with TypeScript
Questions or ideas for improvements? Share your experiences using useLocalStorage in the comments—especially edge cases you've encountered in production apps. Let's discuss the trade-offs between localStorage, sessionStorage, and other persistence strategies for different use cases!
Google AdSense Placeholder
CONTENT SLOT