Skip to main content

useLocalStorage: Custom Hook for Persistent State

Persisting state across page refreshes is critical for user experience. Instead of manually managing the repetitive boilerplate of reading and writing to the browser's localStorage API, you can create a reusable useLocalStorage custom hook that works like useState but automatically persists data. This guide teaches you to build that hook from scratch and apply it to real use cases.

Key Takeaways

  • useLocalStorage abstracts localStorage boilerplate by providing a useState-like API that automatically persists state to the browser
  • Read from localStorage in useState's initializer function to ensure data loads on first render, not after
  • Use useEffect to sync the value to localStorage whenever it changes, with key and value in the dependency array
  • Always wrap localStorage access in try/catch because it can throw errors (quota exceeded, private browsing, etc.)
  • JSON.stringify stores values as text; JSON.parse retrieves them, letting you persist objects and arrays, not just primitives

The Problem: Repetitive Storage Boilerplate

Managing persistent state manually creates boilerplate in every component:

// Without a custom hook—repetitive in every component
const [count, setCount] = useState(() => {
const saved = localStorage.getItem('count');
return saved ? JSON.parse(saved) : 0;
});

useEffect(() => {
localStorage.setItem('count', JSON.stringify(count));
}, [count]);

The hook pattern solves this: extract the logic once, reuse it everywhere:

// With custom hook—clean and reusable
const [count, setCount] = useLocalStorage('count', 0);

Building the useLocalStorage Hook

Here's the complete hook implementation:

import { useState, useEffect } from 'react';

function getStorageValue(key, defaultValue) {
// Read from localStorage, parse JSON, handle missing key
const saved = localStorage.getItem(key);

if (!saved) {
return defaultValue;
}

try {
return JSON.parse(saved);
} catch (error) {
console.error(`Failed to parse localStorage value for key "${key}":`, error);
return defaultValue;
}
}

function useLocalStorage(key, defaultValue) {
// Initialize from localStorage on first render only
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue);
});

// Sync value to localStorage whenever it changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Failed to save to localStorage for key "${key}":`, error);
}
}, [key, value]);

return [value, setValue];
}

export default useLocalStorage;

How it works:

  1. getStorageValue(key, defaultValue): Helper function that retrieves a value from localStorage. If the key doesn't exist or JSON parsing fails, return the default value. Error handling prevents crashes from corrupted data.

  2. useState(() => getStorageValue(...)): Pass a function to useState's initializer. This function runs only once on first render, reading from localStorage without re-reading on every render (performance optimization).

  3. useEffect: Whenever value changes, save it to localStorage as JSON. The dependency array [key, value] ensures this syncs whenever either changes.

  4. Error handling: Wrap both getStorageValue and setItem in try/catch. localStorage can fail if quota is exceeded, disabled in private browsing, or data is corrupted.

Using useLocalStorage in Components

Once you've created the hook, using it is simple. Here's a persistent counter:

import React from 'react';
import useLocalStorage from './useLocalStorage';

function PersistentCounter() {
const [count, setCount] = useLocalStorage('app-counter', 0);

return (
<div>
<h1>Persistent Counter</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}

export default PersistentCounter;

Increment the counter, refresh the page, and the count persists. The hook handles all the localStorage logic.

Example: Persistent Theme Switcher

Here's a realistic component using useLocalStorage for theme preferences:

import React from 'react';
import useLocalStorage from './useLocalStorage';

function ThemeSwitcher() {
const [theme, setTheme] = useLocalStorage('app-theme', 'light');

const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};

const bgColor = theme === 'light' ? '#ffffff' : '#1a1a1a';
const textColor = theme === 'light' ? '#000000' : '#ffffff';

return (
<div style={{ backgroundColor: bgColor, color: textColor, padding: '20px' }}>
<h1>Current Theme: {theme}</h1>
<button onClick={toggleTheme}>Toggle Theme</button>
<p>Refresh the page—the theme persists!</p>
</div>
);
}

export default ThemeSwitcher;

User toggles theme, closes the browser, returns later: the theme preference is still there.

Example: Persistent Form Data

Prevent data loss in long forms by auto-saving to localStorage:

import React from 'react';
import useLocalStorage from './useLocalStorage';

function SignupForm() {
const [formData, setFormData] = useLocalStorage('signup-form', {
firstName: '',
email: '',
subscribe: false,
});

const handleChange = (e) => {
const { name, type, value, checked } = e.target;
const newValue = type === 'checkbox' ? checked : value;

setFormData(prev => ({
...prev,
[name]: newValue
}));
};

const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitting:', formData);
// Clear form after submission
setFormData({ firstName: '', email: '', subscribe: false });
};

return (
<form onSubmit={handleSubmit}>
<div>
<label>First Name:</label>
<input
name="firstName"
type="text"
value={formData.firstName}
onChange={handleChange}
/>
</div>

<div>
<label>Email:</label>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
</div>

<div>
<label>
<input
name="subscribe"
type="checkbox"
checked={formData.subscribe}
onChange={handleChange}
/>
Subscribe to newsletter
</label>
</div>

<button type="submit">Submit</button>
</form>
);
}

export default SignupForm;

User types form data, accidentally closes the tab, reopens the form: their data is still there. No data loss.

Advanced Pattern: Error Handling and Validation

For production, add validation and error handling:

import { useState, useEffect } from 'react';

function useLocalStorage(key, defaultValue, options = {}) {
const { serializer = JSON, deserializer = JSON } = options;

const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
if (item === null) return defaultValue;
return deserializer.parse(item);
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return defaultValue;
}
});

useEffect(() => {
try {
window.localStorage.setItem(key, serializer.stringify(value));
} catch (error) {
console.warn(`Error writing to localStorage key "${key}":`, error);
// Optionally clear old entries if quota exceeded
if (error.name === 'QuotaExceededError') {
console.error('localStorage quota exceeded');
}
}
}, [key, value, serializer]);

return [value, setValue];
}

export default useLocalStorage;

This version allows custom serializers, handles quota exceeded errors, and logs warnings without crashing.

Frequently Asked Questions

Why use useState's initializer function instead of useEffect?

If you read from localStorage in useEffect, the component renders first with the default value, then updates when the effect runs. This causes a visible flicker (hydration mismatch). Reading in the useState initializer ensures the correct value renders immediately on first mount.

Can I store objects and arrays in useLocalStorage?

Yes. The hook uses JSON.stringify to convert objects to text before storage, and JSON.parse to convert back. This works for any JSON-serializable value (objects, arrays, primitives, but not functions or circular references).

What happens if localStorage is full?

localStorage has a size limit (usually 5–10 MB). If exceeded, setItem throws a QuotaExceededError. The hook catches this and logs it, preventing the app from crashing. In production, consider clearing old entries or using IndexedDB for large data.

How do I clear a value from localStorage?

Call the setter with null or your defaultValue:

const [count, setCount] = useLocalStorage('count', 0);
setCount(0); // Clear to default
// Or manually: localStorage.removeItem('count');

Can I use useLocalStorage in multiple tabs at once?

The hook works in each tab independently. If you want to sync state across tabs, listen to the storage event (fires when localStorage changes in another tab). That's an advanced pattern for Part 2.

Should I use useLocalStorage for sensitive data?

No. localStorage is not encrypted and is accessible to any JavaScript on your site (including XSS attacks). Never store passwords, tokens, or sensitive data in localStorage. Use secure HTTP-only cookies instead.

Best Practices

  • Use a consistent key naming convention: app-theme, app-counter, signup-form (prefix with app name to avoid conflicts)
  • Always provide a sensible default: The default value is used if localStorage is empty or data is corrupted
  • Error handling is mandatory: Wrap localStorage operations in try/catch because it can throw
  • Test in private browsing: Some browsers disable localStorage in private mode; handle gracefully
  • Clear stale data: Old forms or features may accumulate; periodically clean localStorage
  • Never store sensitive data: localStorage is accessible to JavaScript; use HTTP-only cookies for auth tokens

Further Reading

Learn more about React hooks, browser storage, and state persistence:


Glossary

useLocalStorage: Custom React hook for persisting state in the browser's localStorage with a useState-like API.

localStorage: Browser Web Storage API allowing persistent key-value storage with no expiration date.

JSON.stringify(): Converts JavaScript values to JSON text for storage.

JSON.parse(): Parses JSON text back into JavaScript values.

Hydration: Process where React renders initial HTML and then "hydrates" it with JavaScript. A mismatch causes flicker.

QuotaExceededError: Error thrown when localStorage exceeds its size limit (typically 5–10 MB).

Custom Hook: JavaScript function starting with "use" that encapsulates stateful logic and reuses it across components.