useLocalStorage Hook: Handle Objects, Sync Tabs (Part 2)
This guide extends the basic useLocalStorage hook with robust object/array handling using JSON serialization and real-time cross-tab synchronization via the browser's storage event. Use this pattern to persist user preferences, form data, or any state across page refreshes and multiple browser windows.
Key Takeaways
- Serialize complex data:
JSON.stringify()for storage,JSON.parse()for retrieval; wrap in try-catch to handle errors - Cross-tab sync: Listen to the
storageevent (fires when another tab modifies localStorage) and update local state - Full hook implementation: Initialize state from localStorage, sync on write, listen for external changes, cleanup listeners
- Error handling: Always wrap
JSON.parse()in try-catch; return default value if parsing fails
The Challenge: Objects, Arrays, and Multi-Tab Apps
The basic useLocalStorage hook works for strings and numbers, but modern apps need:
- Complex data types: Theme objects
{mode: 'dark', accent: 'blue'}, user preferences arrays, form state—localStorage only stores strings. - Cross-tab synchronization: A user changes the theme in Tab 1; Tab 2 should see the change instantly (critical for real-world apps).
Enhanced useLocalStorage Hook Implementation
Here's a production-ready hook that handles both challenges:
// useLocalStorage.js
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage(key, defaultValue) {
// Step 1: Initialize state from localStorage or use default
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
if (saved !== null) {
return JSON.parse(saved);
}
return defaultValue;
} catch (error) {
console.error(`Error parsing stored value for "${key}":`, error);
return defaultValue;
}
});
// Step 2: Listen to storage events from other tabs
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key && event.newValue !== null) {
try {
setValue(JSON.parse(event.newValue));
} catch (error) {
console.error(`Error parsing storage event for "${key}":`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
// Cleanup: remove listener when component unmounts
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key]);
// Step 3: Update both state and localStorage together
const setStoredValue = useCallback((newValue) => {
try {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
} catch (error) {
console.error(`Error saving to localStorage for "${key}":`, error);
}
}, [key]);
return [value, setStoredValue];
}
export default useLocalStorage;
How it works:
- Initialize from localStorage: On mount, try to read and parse the stored value. If storage is empty or parsing fails, use the default.
- Listen to storage events: When another tab modifies localStorage, the
storageevent fires. Update local state to match. - Write to storage: Custom setter updates both React state and localStorage atomically.
- Error handling: Wrap all JSON operations in try-catch to prevent crashes if data is corrupted.
Real-World Example: Cross-Tab Theme Switcher
// ThemeSwitcher.jsx
import React from 'react';
import useLocalStorage from './useLocalStorage';
import './theme.css';
function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage('app-theme', {
mode: 'light',
accentColor: '#007bff'
});
const toggleMode = () => {
setTheme({
...theme,
mode: theme.mode === 'light' ? 'dark' : 'light'
});
};
const changeAccent = (color) => {
setTheme({
...theme,
accentColor: color
});
};
return (
<div className={`app app-${theme.mode}`}>
<h1>Theme Switcher</h1>
<p>Current Mode: <strong>{theme.mode}</strong></p>
<p>Current Accent: <strong>{theme.accentColor}</strong></p>
<button onClick={toggleMode}>
Toggle to {theme.mode === 'light' ? 'Dark' : 'Light'} Mode
</button>
<div className="color-picker">
<button
onClick={() => changeAccent('#007bff')}
style={{ backgroundColor: '#007bff' }}
>
Blue
</button>
<button
onClick={() => changeAccent('#28a745')}
style={{ backgroundColor: '#28a745' }}
>
Green
</button>
<button
onClick={() => changeAccent('#dc3545')}
style={{ backgroundColor: '#dc3545' }}
>
Red
</button>
</div>
</div>
);
}
export default ThemeSwitcher;
To test cross-tab sync:
- Open the app in two browser tabs
- Click "Toggle to Dark Mode" in Tab 1
- Watch Tab 2 update instantly without page refresh
The storage event fired in Tab 1 is received by Tab 2's listener, which calls setTheme() with the new value.
Handling Form Data with useLocalStorage
import useLocalStorage from './useLocalStorage';
function ContactForm() {
const [formData, setFormData] = useLocalStorage('contact-form', {
name: '',
email: '',
message: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value
});
};
const handleSubmit = (e) => {
e.preventDefault();
// Submit form (send to API)
console.log('Submitting:', formData);
// Clear form after successful submission
setFormData({ name: '', email: '', message: '' });
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="name"
placeholder="Your name"
value={formData.name}
onChange={handleChange}
/>
<input
type="email"
name="email"
placeholder="Your email"
value={formData.email}
onChange={handleChange}
/>
<textarea
name="message"
placeholder="Your message"
value={formData.message}
onChange={handleChange}
/>
<button type="submit">Send</button>
</form>
);
}
export default ContactForm;
If the user accidentally closes the tab, their draft data persists. Refresh the page or open a new tab—the form refills automatically.
Understanding the storage Event
The storage event fires in ALL tabs except the one that made the change. Event properties include:
event.key: The localStorage key that changedevent.newValue: The new value (JSON string)event.oldValue: The previous valueevent.storageArea: The storage object (localStorage or sessionStorage)
window.addEventListener('storage', (event) => {
if (event.key === 'app-theme') {
console.log('Another tab changed the theme:', event.newValue);
}
});
Important: The storage event does NOT fire in the tab that made the change. That's why we update state directly in setStoredValue() rather than waiting for the event.
Advanced Pattern: useLocalStorage with Functional Updates
For complex state like arrays or nested objects, accept a function:
function useLocalStorage(key, defaultValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : defaultValue;
} catch {
return defaultValue;
}
});
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key && event.newValue !== null) {
try {
setValue(JSON.parse(event.newValue));
} catch (error) {
console.error('Storage parse error:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
const setStoredValue = useCallback((newValue) => {
try {
// Accept both value and function (like setState)
const valueToStore =
newValue instanceof Function ? newValue(value) : newValue;
setValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Storage write error:', error);
}
}, [key, value]);
return [value, setStoredValue];
}
Now you can use it with a function callback:
const [todos, setTodos] = useLocalStorage('todos', []);
// Add a todo (functional update)
setTodos(prevTodos => [...prevTodos, newTodo]);
// Or direct value
setTodos([newTodo]);
Frequently Asked Questions
What happens if localStorage is full?
localStorage has a size limit (~5–10 MB per origin). If you exceed it, setItem() throws a QuotaExceededError. Your try-catch handles this gracefully and logs the error.
Can I use useLocalStorage in Server-Side Rendering (SSR)?
No—localStorage only exists in the browser. For SSR frameworks like Next.js, check typeof window !== 'undefined' before accessing localStorage.
How do I clear localStorage for a specific key?
const [value, setStoredValue] = useLocalStorage('key', null);
// Clear by setting to null or calling remove
localStorage.removeItem('key');
// Or set to default:
setStoredValue(defaultValue);
Does the storage event work with sessionStorage?
Yes, the same event fires for both. Check event.storageArea if you manage both types.
Can I use useLocalStorage with large arrays or objects?
Yes, but be mindful of size limits. For data over 1 MB, consider IndexedDB instead (more complex but no size limit).
What if two tabs try to write to localStorage simultaneously?
Writes are atomic at the OS level, so one always completes first. The second write overwrites the first. For critical data, add timestamps or versioning logic.