Lightweight Store with Vue Reactive
When it comes to state management in Vue applications, Pinia and Vuex are often the go-to solutions. However, for smaller applications or when you want a more lightweight approach, Vue's built-in reactive
system can be a powerful alternative. Let's explore how to create a simple yet effective store using reactive
.
The Store Implementation
Here's a clean implementation that combines state, methods, and getters:
// stores/createStore.js
import { reactive } from 'vue'
export function createStore({ state, methods = {}, getters = {} }) {
const store = reactive({
...state,
...methods,
...Object.entries(getters).reduce((acc, [key, fn]) => ({
...acc,
get [key]() { return fn(store) }
}), {})
})
if (import.meta.hot) {
const savedState = { ...state }
import.meta.hot.dispose(() => {
Object.assign(savedState, store)
})
import.meta.hot.accept(() => {
Object.assign(store, savedState)
})
}
return store
}
How It Works
Let's break down the key aspects of this implementation:
1. State Management
The store combines three main parts, following Vue's familiar terminology:
state
: The base state of your store (like Vue'sdata()
)methods
: Functions to modify the state (like Vue'smethods
option)getters
: Computed properties derived from the state (like Vue'scomputed
)
const store = reactive({
...state, // Base state (data)
...methods, // Functions (methods)
...Object.entries(getters).reduce((acc, [key, fn]) => ({
...acc,
get [key]() { return fn(store) }
}), {}) // Computed properties (computed)
})
2. Getters Implementation
The getters are implemented using computed properties:
Object.entries(getters).reduce((acc, [key, fn]) => ({
...acc,
get [key]() { return fn(store) }
}), {})
This creates computed properties that automatically update when their dependencies change.
3. Hot Module Replacement
The HMR support ensures state persistence during development:
if (import.meta.hot) {
const savedState = { ...state }
import.meta.hot.dispose(() => {
Object.assign(savedState, store)
})
import.meta.hot.accept(() => {
Object.assign(store, savedState)
})
}
Usage Example
Here's how to use this store in your application:
// stores/counter.js
import { createStore } from './createStore'
export const useCounterStore = createStore({
state: {
count: 0
},
methods: {
increment() {
this.count++
},
decrement() {
this.count--
}
},
getters: {
doubleCount: (state) => state.count * 2,
isPositive: (state) => state.count > 0
}
})
Using the store in a component:
<script setup>
import { useCounterStore } from './stores/counter'
const counter = useCounterStore()
</script>
<template>
<div>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<p>Is Positive: {{ counter.isPositive }}</p>
<button @click="counter.increment">+</button>
<button @click="counter.decrement">-</button>
</div>
</template>
Project Size and Usage
This implementation is versatile and can be used across different project sizes. Let's explore how it adapts to various scenarios:
Small to Medium Projects
For smaller applications, this store provides:
- Simple and intuitive API
- No external dependencies
- Easy setup and maintenance
- Perfect for MVPs and prototypes
Large Projects
The store is also powerful enough for larger applications. Here's how you can use it effectively:
TypeScript Support
The store works perfectly with TypeScript, providing type safety and better IDE support:
// stores/types.ts
interface User {
id: string
name: string
email: string
preferences: UserPreferences
}
interface UserPreferences {
theme: 'light' | 'dark'
notifications: boolean
language: string
}
interface AuthState {
user: User | null
isAuthenticated: boolean
loading: boolean
error: string | null
}
// stores/auth.ts
import { createStore } from './createStore'
import type { AuthState } from './types'
export const useAuthStore = createStore({
state: {
user: null,
isAuthenticated: false,
loading: false,
error: null
} as AuthState,
methods: {
async login(email: string, password: string) {
this.loading = true
this.error = null
try {
const response = await api.login(email, password)
this.user = response.user
this.isAuthenticated = true
} catch (error) {
this.error = error.message
} finally {
this.loading = false
}
},
logout() {
this.user = null
this.isAuthenticated = false
},
updatePreferences(preferences: Partial<UserPreferences>) {
if (this.user) {
this.user.preferences = { ...this.user.preferences, ...preferences }
}
}
},
getters: {
userEmail: (state) => state.user?.email,
isDarkMode: (state) => state.user?.preferences.theme === 'dark',
hasError: (state) => !!state.error
}
})
Using the store in a component:
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
<template>
<div>
<div v-if="auth.loading">Loading...</div>
<div v-else-if="auth.isAuthenticated">
<h1>Welcome, {{ auth.user?.name }}</h1>
<p>Email: {{ auth.userEmail }}</p>
<button @click="auth.logout">Logout</button>
</div>
<div v-else>
<form @submit.prevent="auth.login(email, password)">
<!-- login form -->
</form>
</div>
</div>
</template>
Benefits for Larger Projects
Modularity:
- Each feature can have its own store
- Stores can be composed and reused
- Easy to maintain separation of concerns
Developer Experience:
- Simple API that's easy to understand
- Less boilerplate than Pinia/Vuex
- Better debugging (you can see exactly what changed)
Testing:
- Easy to test as it's just plain JavaScript
- Can mock the store easily
- Methods are just functions
Performance:
- No unnecessary re-renders
- Smaller bundle size
- Better memory usage
When to Consider Alternatives
While this solution is powerful, there are cases where you might want to consider Pinia or Vuex:
- When you need DevTools integration
- When you require a complex plugin system
- When you need built-in persistence (like localStorage, IndexedDB, or other storage solutions)
- When you need complex state normalization
Note: While you can implement persistence yourself with this store (using localStorage
, IndexedDB
, etc.), Pinia and Vuex provide built-in persistence plugins that handle edge cases and provide more features out of the box.
Conclusion
While Pinia and Vuex are excellent solutions for complex applications, Vue's reactive
system provides a powerful foundation for state management needs. This implementation offers a clean, maintainable, and efficient way to manage state in your Vue applications.
Remember that this is just one approach to state management. Choose the solution that best fits your application's needs, considering factors like:
- Application size
- State complexity
- Team preferences
- Performance requirements
Advanced: Implementing Persistence
While this store doesn't include built-in persistence, you can easily add it. Here's how to implement localStorage persistence:
// stores/createStore.js
import { reactive, watch } from 'vue'
export function createStore({
state,
methods = {},
getters = {},
persist
}) {
// Initialize state from storage if available
let initialState = { ...state }
if (persist) {
const storage = persist.storage || localStorage
const stored = storage.getItem(persist.key)
if (stored) {
const parsed = JSON.parse(stored)
// Only restore specified paths if provided
if (persist.paths) {
persist.paths.forEach(path => {
const value = path.split('.').reduce((obj, key) => obj?.[key], parsed)
if (value !== undefined) {
path.split('.').reduce((obj, key, i, arr) => {
if (i === arr.length - 1) {
obj[key] = value
} else {
obj[key] = obj[key] || {}
return obj[key]
}
}, initialState)
}
})
} else {
initialState = parsed
}
}
}
const store = reactive({
...initialState,
...methods,
...Object.entries(getters).reduce((acc, [key, fn]) => ({
...acc,
get [key]() { return fn(store) }
}), {})
})
// Save to storage on changes
if (persist) {
const storage = persist.storage || localStorage
watch(
() => persist.paths
? persist.paths.map(path =>
path.split('.').reduce((obj, key) => obj?.[key], store)
)
: store,
(newValue) => {
const toStore = persist.paths
? persist.paths.reduce((acc, path) => {
path.split('.').reduce((obj, key, i, arr) => {
if (i === arr.length - 1) {
obj[key] = path.split('.').reduce((o, k) => o?.[k], store)
} else {
obj[key] = obj[key] || {}
return obj[key]
}
}, acc)
return acc
}, {})
: newValue
storage.setItem(persist.key, JSON.stringify(toStore))
},
{ deep: true }
)
}
if (import.meta.hot) {
const savedState = { ...initialState }
import.meta.hot.dispose(() => {
Object.assign(savedState, store)
})
import.meta.hot.accept(() => {
Object.assign(store, savedState)
})
}
return store
}
Using the persistent store in JavaScript:
// stores/user.js
export const useUserStore = createStore({
state: {
preferences: {
theme: 'light',
language: 'en'
},
recentItems: []
},
methods: {
setTheme(theme) {
this.preferences.theme = theme
},
addRecentItem(item) {
this.recentItems = [item, ...this.recentItems].slice(0, 10)
}
},
persist: {
key: 'user-store',
// Only persist preferences and recent items
paths: ['preferences', 'recentItems']
}
})
And here's the TypeScript version for comparison:
// stores/createStore.ts
import { reactive, watch } from 'vue'
interface StoreOptions<T> {
state: T
methods?: Record<string, Function>
getters?: Record<string, Function>
persist?: {
key: string
storage?: Storage
paths?: string[]
}
}
export function createStore<T extends object>({
state,
methods = {},
getters = {},
persist
}: StoreOptions<T>) {
// Initialize state from storage if available
let initialState = { ...state }
if (persist) {
const storage = persist.storage || localStorage
const stored = storage.getItem(persist.key)
if (stored) {
const parsed = JSON.parse(stored)
// Only restore specified paths if provided
if (persist.paths) {
persist.paths.forEach(path => {
const value = path.split('.').reduce((obj, key) => obj?.[key], parsed)
if (value !== undefined) {
path.split('.').reduce((obj, key, i, arr) => {
if (i === arr.length - 1) {
obj[key] = value
} else {
obj[key] = obj[key] || {}
return obj[key]
}
}, initialState)
}
})
} else {
initialState = parsed
}
}
}
const store = reactive({
...initialState,
...methods,
...Object.entries(getters).reduce((acc, [key, fn]) => ({
...acc,
get [key]() { return fn(store) }
}), {})
})
// Save to storage on changes
if (persist) {
const storage = persist.storage || localStorage
watch(
() => persist.paths
? persist.paths.map(path =>
path.split('.').reduce((obj, key) => obj?.[key], store)
)
: store,
(newValue) => {
const toStore = persist.paths
? persist.paths.reduce((acc, path) => {
path.split('.').reduce((obj, key, i, arr) => {
if (i === arr.length - 1) {
obj[key] = path.split('.').reduce((o, k) => o?.[k], store)
} else {
obj[key] = obj[key] || {}
return obj[key]
}
}, acc)
return acc
}, {})
: newValue
storage.setItem(persist.key, JSON.stringify(toStore))
},
{ deep: true }
)
}
if (import.meta.hot) {
const savedState = { ...initialState }
import.meta.hot.dispose(() => {
Object.assign(savedState, store)
})
import.meta.hot.accept(() => {
Object.assign(store, savedState)
})
}
return store
}
Using the persistent store in TypeScript:
// stores/user.ts
interface UserState {
preferences: {
theme: 'light' | 'dark'
language: string
}
recentItems: string[]
}
export const useUserStore = createStore({
state: {
preferences: {
theme: 'light',
language: 'en'
},
recentItems: []
} as UserState,
methods: {
setTheme(theme: 'light' | 'dark') {
this.preferences.theme = theme
},
addRecentItem(item: string) {
this.recentItems = [item, ...this.recentItems].slice(0, 10)
}
},
persist: {
key: 'user-store',
// Only persist preferences and recent items
paths: ['preferences', 'recentItems']
}
})
This implementation provides several features:
- Selective Persistence: Only persist specific paths in the state
- Storage Flexibility: Use any storage implementation (localStorage, sessionStorage, etc.)
- Type Safety: Full TypeScript support (when using TypeScript)
- Automatic Updates: Changes are automatically saved to storage
- Deep Watching: Properly handles nested state changes
The store will now automatically:
- Load initial state from storage on creation
- Save state changes to storage
- Only persist specified paths (if configured)
- Handle nested state updates correctly