Skip to main content

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 storage event (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:

  1. Complex data types: Theme objects {mode: 'dark', accent: 'blue'}, user preferences arrays, form state—localStorage only stores strings.
  2. 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:

  1. Initialize from localStorage: On mount, try to read and parse the stored value. If storage is empty or parsing fails, use the default.
  2. Listen to storage events: When another tab modifies localStorage, the storage event fires. Update local state to match.
  3. Write to storage: Custom setter updates both React state and localStorage atomically.
  4. 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:

  1. Open the app in two browser tabs
  2. Click "Toggle to Dark Mode" in Tab 1
  3. 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 changed
  • event.newValue: The new value (JSON string)
  • event.oldValue: The previous value
  • event.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.

Further Reading​