0 / 0
Skip to content

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:

js
// 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's data())
  • methods: Functions to modify the state (like Vue's methods option)
  • getters: Computed properties derived from the state (like Vue's computed)
js
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:

js
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:

js
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:

js
// 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:

vue
<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:

ts
// 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:

vue
<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

  1. Modularity:

    • Each feature can have its own store
    • Stores can be composed and reused
    • Easy to maintain separation of concerns
  2. Developer Experience:

    • Simple API that's easy to understand
    • Less boilerplate than Pinia/Vuex
    • Better debugging (you can see exactly what changed)
  3. Testing:

    • Easy to test as it's just plain JavaScript
    • Can mock the store easily
    • Methods are just functions
  4. 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:

js
// 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:

js
// 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:

ts
// 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:

ts
// 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:

  1. Selective Persistence: Only persist specific paths in the state
  2. Storage Flexibility: Use any storage implementation (localStorage, sessionStorage, etc.)
  3. Type Safety: Full TypeScript support (when using TypeScript)
  4. Automatic Updates: Changes are automatically saved to storage
  5. 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