
Vue 3’s Composition API changed how we write reusable logic. Instead of mixins or render props, we now have composables - functions that encapsulate reactive state and logic. In this post, we’ll build a production-ready useLocalStorage composable from scratch, learning about Vue’s reactivity system, browser storage events, and handling edge cases along the way.
The Problem We’re Solving
You’re building a Vue app and want to persist user preferences - theme choice, language settings, or shopping cart items. You could manually call localStorage.setItem() and localStorage.getItem() everywhere, but that’s repetitive and error-prone. What if we could make localStorage feel like reactive Vue state?
<script setup>
// Instead of this mess:
const theme = ref(localStorage.getItem('theme') || 'light');
watch(theme, (value) => {
localStorage.setItem('theme', value);
});
// We want this:
const theme = useLocalStorage('theme', 'light');
</script>
Let’s build it step by step!
Step 1: The Simplest Version
At its core, we need to:
- Read from localStorage on mount
- Write to localStorage when the value changes
- Make it reactive with Vue’s
ref
import { ref, watch } from 'vue';
export function useLocalStorage(key: string, defaultValue: any) {
// Read initial value from localStorage
const storedValue = localStorage.getItem(key);
const item = ref(storedValue || defaultValue);
// Watch for changes and update localStorage
watch(item, (value) => {
localStorage.setItem(key, value);
});
return item;
}
Usage:
<script setup>
import { useLocalStorage } from './composables/useLocalStorage';
const username = useLocalStorage('username', 'Guest');
</script>
<template>
<input v-model="username" />
<p>Hello, {{ username }}!</p>
</template>
This works, but it has problems:
- ❌ Can’t store objects (localStorage only stores strings)
- ❌ Can’t remove items
- ❌ Doesn’t sync across browser tabs
Step 2: JSON Serialization
localStorage only stores strings, so we need to serialize and deserialize values:
import { ref, watch } from 'vue';
export function useLocalStorage(key: string, defaultValue: any) {
// Parse JSON when reading
const storedValue = localStorage.getItem(key);
const item = ref(storedValue ? JSON.parse(storedValue) : defaultValue);
// Stringify JSON when writing
watch(item, (value) => {
localStorage.setItem(key, JSON.stringify(value));
});
return item;
}
Now we can store objects:
<script setup>
const user = useLocalStorage('user', { name: 'Alice', age: 25 });
// This works now!
user.value = { name: 'Bob', age: 30 };
</script>
Step 3: Error Handling
JSON.parse() can throw errors with corrupted data. Let’s handle that gracefully:
import { ref, watch } from 'vue';
export function useLocalStorage(key: string, defaultValue: any) {
let initialValue;
try {
const storedValue = localStorage.getItem(key);
initialValue = storedValue ? JSON.parse(storedValue) : defaultValue;
} catch (e) {
console.warn(`Error parsing localStorage key "${key}":`, e);
initialValue = defaultValue;
}
const item = ref(initialValue);
watch(item, (value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error(`Error saving to localStorage key "${key}":`, e);
}
});
return item;
}
Why wrap both parse and stringify?
JSON.parse()throws when data is corruptedlocalStorage.setItem()throws when storage is full (quota exceeded)JSON.stringify()throws with circular references
Step 4: Removing Items
What if we want to remove an item from localStorage? Setting it to null should delete it:
import { ref, watch } from 'vue';
export function useLocalStorage(key: string, defaultValue: any = null) {
let initialValue;
try {
initialValue = JSON.parse(localStorage.getItem(key) || "null") || defaultValue;
} catch (e) {
console.warn(`Error parsing localStorage key "${key}":`, e);
initialValue = defaultValue;
}
const item = ref(initialValue);
watch(item, (value) => {
if (!value) {
// Remove from localStorage if falsy
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
});
return item;
}
Now we can clear values:
<script setup>
const token = useLocalStorage('auth-token', null);
// Save token
token.value = 'abc123';
// Clear token (removes from localStorage)
token.value = null;
</script>
Note: We use || "null" because localStorage.getItem() returns null when the key doesn’t exist, and JSON.parse(null) throws an error. Using "null" as the fallback makes it parse to JavaScript’s null value.
Step 5: Deep Reactivity for Objects
Our current implementation has a subtle bug with nested objects:
<script setup>
const settings = useLocalStorage('settings', { theme: 'dark', lang: 'en' });
// This triggers the watcher (reassignment)
settings.value = { theme: 'light', lang: 'en' };
// This DOESN'T trigger the watcher! 😱
settings.value.theme = 'light';
</script>
By default, Vue’s watch only tracks the root reference. We need to enable deep watching:
import { ref, watch } from 'vue';
export function useLocalStorage(
key: string,
defaultValue: any = null,
deep = false // Allow caller to opt-in to deep watching
) {
let initialValue;
try {
initialValue = JSON.parse(localStorage.getItem(key) || "null") || defaultValue;
} catch (e) {
initialValue = defaultValue;
}
const item = ref(initialValue);
watch(item, (value) => {
if (!value) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
}, { deep }); // Enable deep watching if requested
return item;
}
Now we can track nested changes:
<script setup>
const settings = useLocalStorage('settings', { theme: 'dark' }, true); // deep = true
// This now saves to localStorage!
settings.value.theme = 'light';
</script>
Why make deep optional? Deep watching has a performance cost - Vue needs to traverse the entire object tree. For simple values (strings, numbers), it’s unnecessary overhead.
Step 6: Cross-Tab Synchronization
Here’s where things get interesting. Open your app in two browser tabs. Change localStorage in one tab… and nothing happens in the other!
Browsers fire a storage event when localStorage changes in another tab/window. We can listen to it:
import { onMounted, onUnmounted, ref, watch } from 'vue';
export function useLocalStorage(key: string, defaultValue: any = null, deep = false) {
let initialValue;
try {
initialValue = JSON.parse(localStorage.getItem(key) || "null") || defaultValue;
} catch (e) {
initialValue = defaultValue;
}
const item = ref(initialValue);
watch(item, (value) => {
if (!value) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
}, { deep });
// Listen for changes in other tabs
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
try {
item.value = JSON.parse(event.newValue || "null");
} catch (e) {
console.warn(`Error parsing storage event for key "${key}":`, e);
item.value = defaultValue;
}
}
};
onMounted(() => {
window.addEventListener('storage', handleStorageChange);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
});
return item;
}
Understanding the storage Event:
- Fires when localStorage changes in another tab/window
- Does not fire in the tab that made the change
- Provides
event.key,event.oldValue, andevent.newValue
Now our tabs stay in sync! Change the theme in one tab, and all other tabs update automatically.
The Complete Solution
Here’s our final, production-ready composable:
import { onMounted, onUnmounted, ref, watch } from 'vue';
export function useLocalStorage(key: string, defaultValue: any = null, deep = false) {
let initialValue;
try {
initialValue = JSON.parse(localStorage.getItem(key) || "null") || defaultValue;
} catch (e) {
initialValue = defaultValue;
}
const item = ref(initialValue);
watch(item, (value) => {
if (!value) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify(value));
}, { deep });
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
try {
item.value = JSON.parse(event.newValue || "null");
} catch (e) {
console.log("ERROR parsing local storage value");
item.value = defaultValue;
}
}
};
onMounted(() => {
window.addEventListener('storage', handleStorageChange);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
});
return item;
}
Real-World Usage Examples
Theme Switcher
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage';
const theme = useLocalStorage('theme', 'light');
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
};
</script>
<template>
<div :class="theme">
<button @click="toggleTheme">
Toggle to {{ theme === 'light' ? 'Dark' : 'Light' }} Mode
</button>
</div>
</template>
<style scoped>
.light { background: white; color: black; }
.dark { background: #1a1a1a; color: white; }
</style>
Shopping Cart
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage';
const cart = useLocalStorage('shopping-cart', [], true); // deep = true for arrays
const addToCart = (product: Product) => {
cart.value.push(product);
};
const removeFromCart = (index: number) => {
cart.value.splice(index, 1);
};
const clearCart = () => {
cart.value = null; // Removes from localStorage
};
</script>
<template>
<div>
<h2>Cart ({{ cart?.length || 0 }} items)</h2>
<ul>
<li v-for="(item, i) in cart" :key="i">
{{ item.name }} - ${{ item.price }}
<button @click="removeFromCart(i)">Remove</button>
</li>
</ul>
<button @click="clearCart">Clear Cart</button>
</div>
</template>
User Preferences
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage';
const preferences = useLocalStorage('user-prefs', {
language: 'en',
notifications: true,
fontSize: 16
}, true); // deep = true for nested object
</script>
<template>
<div>
<select v-model="preferences.language">
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
<label>
<input type="checkbox" v-model="preferences.notifications" />
Enable Notifications
</label>
<input
type="range"
v-model.number="preferences.fontSize"
min="12"
max="24"
/>
</div>
</template>
Future Enhancements
Our composable is solid, but here are ideas to make it even better:
1. Storage Quota Handling
Detect when localStorage is full and provide a callback:
export function useLocalStorage(
key: string,
defaultValue: any = null,
options: {
deep?: boolean;
onQuotaExceeded?: () => void;
} = {}
) {
// ...
watch(item, (value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
if (e.name === 'QuotaExceededError') {
options.onQuotaExceeded?.();
}
}
}, { deep: options.deep });
}
2. Custom Serialization
Support non-JSON serializable data (Dates, Maps, Sets):
export function useLocalStorage(
key: string,
defaultValue: any,
options: {
serializer?: {
read: (value: string) => any;
write: (value: any) => string;
}
} = {}
) {
const serializer = options.serializer || {
read: JSON.parse,
write: JSON.stringify
};
// Use custom serializer instead of JSON
}
3. SSR Support
Make it work with Nuxt/SSR by checking for window:
export function useLocalStorage(key: string, defaultValue: any = null, deep = false) {
if (typeof window === 'undefined') {
// Return a plain ref on server
return ref(defaultValue);
}
// Browser code...
}
4. Encryption
Encrypt sensitive data before storing:
export function useLocalStorage(
key: string,
defaultValue: any,
options: {
encrypt?: boolean;
encryptionKey?: string;
} = {}
) {
// Use Web Crypto API to encrypt/decrypt
}
5. TTL (Time To Live)
Auto-expire stored values:
export function useLocalStorage(
key: string,
defaultValue: any,
options: {
ttl?: number; // milliseconds
} = {}
) {
// Store { value, timestamp }
// Check if expired on read
}
6. Type Safety
Add better TypeScript support:
export function useLocalStorage<T>(
key: string,
defaultValue: T,
deep = false
): Ref<T> {
// Now TypeScript knows the exact type!
}
// Usage
const count = useLocalStorage<number>('count', 0); // Ref<number>
const user = useLocalStorage<User>('user', null); // Ref<User | null>
7. Multiple Storage Backends
Support sessionStorage or custom storage:
export function useStorage<T>(
key: string,
defaultValue: T,
storage: Storage = localStorage // or sessionStorage
) {
// Same logic, but use `storage` instead of `localStorage`
}
Key Takeaways
- Composables are reusable: Encapsulate logic once, use it everywhere
- Always handle errors: localStorage can fail in many ways
- Mind the types: localStorage only stores strings, use JSON serialization
- Deep watching has costs: Only enable it when needed for nested objects
- Cross-tab sync works well: The
storageevent makes multi-tab apps feel cohesive - Lifecycle hooks matter: Clean up event listeners in
onUnmounted - Make it configurable: Options like
deeplet users optimize for their use case
Comparison with Other Solutions
VueUse’s useLocalStorage:
- More features (SSR support, custom serializers, storage events)
- Larger bundle size
- Battle-tested in production
Our implementation:
- Minimal and focused
- Easy to understand and modify
- Perfect for learning or simple use cases
For production apps, consider VueUse, but understanding how it works makes you a better developer!
Conclusion
We’ve built a production-ready localStorage composable in under 50 lines of code! We learned about Vue’s reactivity system, browser storage APIs, JSON serialization, error handling, and cross-tab communication.
The beauty of Vue 3’s Composition API is that complex features like cross-tab synchronization become simple, reusable functions. Next time you reach for a library, consider building it yourself - you might be surprised how simple it is!
Happy coding! 🚀
Further Reading: