
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 corrupted
- localStorage.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:
