Supercharged Vue 3.0 Reactivity + Pinia Stores

ajinkyax

Ajinkya Borade

Posted on November 19, 2024

Supercharged Vue 3.0 Reactivity + Pinia Stores

At my workplace I was being tasked with creating a mock chat store for internal local dev work, and while doing so I made few notes about Vue (I had some experience, but not with hooks), So this is just my obsidian notes, I hope its useful to you :)

An in-depth guide to mastering Vue's Composition API, reactive patterns, and Pinia store integration. Perfect for developers looking to level up their Vue.js skills.

Table of Contents

  1. Ref and Reactive References
  2. Watch and Reactivity
  3. Composables
  4. Vue Reactivity API (reactive vs ref)
  5. Pinia Store Integration
  6. Practical Examples
  7. Best Practices
  8. Common Gotchas
  9. Advanced Patterns
  10. Testing Strategies
  11. Performance Optimization

Ref and Reactive References

Understanding the foundation of Vue's reactivity system is crucial for building robust applications. This section explores how to manage reactive state using refs and reactive references, with practical examples and TypeScript integration.

What is Ref?

ref is Vue's way of making primitive values reactive. It wraps the value in a reactive object with a .value property.

import { ref } from 'vue'

// Inside Pinia Store
export const useMyStore = defineStore('my-store', () => {
  // Creates a reactive reference
  const count = ref<number>(0)

  // To access or modify:
  function increment() {
    count.value++  // Need .value for refs
  }

  return {
    count,  // When exposed, components can use it without .value
    increment
  }
})
Enter fullscreen mode Exit fullscreen mode

Types of Refs in Stores

// Simple ref
const isLoading = ref<boolean>(false)

// Array ref
const messages = ref<Message[]>([])

// Complex object ref
const currentUser = ref<User | null>(null)

// Ref with undefined
const selectedId = ref<string | undefined>(undefined)
Enter fullscreen mode Exit fullscreen mode

Watch and Reactivity

Mastering Vue's watching capabilities allows you to respond to state changes effectively and create dynamic, responsive applications. Learn how to use watchers efficiently and handle complex reactive patterns.

Basic Watch Usage

import { watch, ref } from 'vue'

export const useMyStore = defineStore('my-store', () => {
  const messages = ref<Message[]>([])

  // Simple watch
  watch(messages, (newMessages, oldMessages) => {
    console.log('Messages changed:', newMessages)
  })
})
Enter fullscreen mode Exit fullscreen mode

Watch Options

// Immediate execution
watch(messages, (newMessages) => {
  // This runs immediately and on changes
}, { immediate: true })

// Deep watching
watch(messages, (newMessages) => {
  // Detects deep object changes
}, { deep: true })

// Multiple sources
watch(
  [messages, selectedId], 
  ([newMessages, newId], [oldMessages, oldId]) => {
    // Triggers when either changes
  }
)
Enter fullscreen mode Exit fullscreen mode

Composables

Composables are the backbone of reusable logic in Vue 3. This section shows you how to create powerful, reusable functionality that can be shared across your entire application while maintaining clean, organized code.

Creating Custom Composables

Composables are reusable stateful logic functions that follow the use prefix convention.

// useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return {
    count,
    doubleCount,
    increment,
    decrement
  }
}
Enter fullscreen mode Exit fullscreen mode

Composable Lifecycle Integration

// useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', update)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })

  return { x, y }
}
Enter fullscreen mode Exit fullscreen mode

Async Composables

// useAsyncData.ts
import { ref, watchEffect } from 'vue'

export function useAsyncData<T>(asyncGetter: () => Promise<T>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  async function fetch() {
    isLoading.value = true
    error.value = null

    try {
      data.value = await asyncGetter()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  watchEffect(() => {
    fetch()
  })

  return {
    data,
    error,
    isLoading,
    refresh: fetch
  }
}
Enter fullscreen mode Exit fullscreen mode

Composable Dependencies

// useUserProfile.ts
import { computed } from 'vue'
import { useAuth } from './useAuth'
import { useApi } from './useApi'

export function useUserProfile() {
  const { user } = useAuth()
  const { get } = useApi()

  const userProfile = computed(() => {
    if (!user.value) return null
    return get(`/users/${user.value.id}/profile`)
  })

  return {
    userProfile
  }
}
Enter fullscreen mode Exit fullscreen mode

Reusable Form Validation

// useFormValidation.ts
import { ref, computed } from 'vue'

export function useFormValidation<T extends Record<string, any>>(initialState: T) {
  const formData = ref(initialState)
  const errors = ref<Partial<Record<keyof T, string>>>({})

  const isValid = computed(() => Object.keys(errors.value).length === 0)

  function validate(rules: Record<keyof T, (value: any) => string | null>) {
    errors.value = {}

    Object.entries(rules).forEach(([field, validator]) => {
      const error = validator(formData.value[field])
      if (error) {
        errors.value[field as keyof T] = error
      }
    })

    return isValid.value
  }

  return {
    formData,
    errors,
    isValid,
    validate
  }
}
Enter fullscreen mode Exit fullscreen mode

Using Composables in Components


<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
import { useMousePosition } from '@/composables/useMousePosition'

const { count, increment } = useCounter()
const { x, y } = useMousePosition()
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
    <p>Mouse position: {{ x }}, {{ y }}</p>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Vue Reactivity API

Deep dive into Vue's powerful reactivity system. Learn how to leverage reactive objects, refs, and computed properties to build dynamic and efficient applications.

Using Reactive Objects

The reactive method creates a reactive proxy of an object, making all of its properties deeply reactive.

// Basic Reactive Object
import { reactive } from 'vue'

interface User {
  name: string
  age: number
  settings: {
    theme: string
    notifications: boolean
  }
}

const user = reactive<User>({
  name: 'John',
  age: 30,
  settings: {
    theme: 'dark',
    notifications: true
  }
})

// Direct property access (no .value needed)
console.log(user.name)
user.age = 31
Enter fullscreen mode Exit fullscreen mode

Reactive vs Ref

// Comparing reactive and ref usage
import { reactive, ref } from 'vue'

// Using ref
const count = ref(0)
const user = ref({
  name: 'John',
  age: 30
})

// Need .value for refs
count.value++
user.value.age++

// Using reactive
const state = reactive({
  count: 0,
  user: {
    name: 'John',
    age: 30
  }
})

// Direct property access
state.count++
state.user.age++
Enter fullscreen mode Exit fullscreen mode

Limitations and Type Handling

// ❌ Limitations of reactive
import { reactive } from 'vue'

// Don't destructure reactive objects
const state = reactive({ count: 0 })
const { count } = state // Loses reactivity!

// ✅ Instead, use computed or methods
import { reactive, computed } from 'vue'

const state = reactive({ count: 0 })
const doubleCount = computed(() => state.count * 2)

// Or keep references to nested objects
const nested = reactive({
  user: {
    profile: {
      name: 'John'
    }
  }
})

// This maintains reactivity
const profile = nested.user.profile
Enter fullscreen mode Exit fullscreen mode

Reactive Arrays and Collections

import { reactive } from 'vue'

interface TodoItem {
  id: number
  text: string
  completed: boolean
}

const todos = reactive<TodoItem[]>([])

// Methods maintain reactivity
function addTodo(text: string) {
  todos.push({
    id: Date.now(),
    text,
    completed: false
  })
}

// Working with reactive collections
const collection = reactive(new Map<string, number>())
collection.set('key', 1)
Enter fullscreen mode Exit fullscreen mode

Combining with Composables

// useTaskManager.ts
import { reactive, computed } from 'vue'

interface Task {
  id: number
  title: string
  completed: boolean
}

export function useTaskManager() {
  const state = reactive({
    tasks: [] as Task[],
    filter: 'all' as 'all' | 'active' | 'completed'
  })

  const filteredTasks = computed(() => {
    switch (state.filter) {
      case 'active':
        return state.tasks.filter(task => !task.completed)
      case 'completed':
        return state.tasks.filter(task => task.completed)
      default:
        return state.tasks
    }
  })

  function addTask(title: string) {
    state.tasks.push({
      id: Date.now(),
      title,
      completed: false
    })
  }

  function toggleTask(id: number) {
    const task = state.tasks.find(t => t.id === id)
    if (task) {
      task.completed = !task.completed
    }
  }

  return {
    state,
    filteredTasks,
    addTask,
    toggleTask
  }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices with Reactive

// ✅ Good Practices
import { reactive, toRefs } from 'vue'

// 1. Use interfaces for type safety
interface State {
  loading: boolean
  error: Error | null
  data: string[]
}

// 2. Initialize all properties
const state = reactive<State>({
  loading: false,
  error: null,
  data: []
})

// 3. Use toRefs when you need to destructure
function useFeature() {
  const state = reactive({
    foo: 1,
    bar: 2
  })

  // Make it safe to destructure
  return toRefs(state)
}

// 4. Avoid nested reactivity when possible
// ❌ Bad
const nested = reactive({
  user: reactive({
    profile: reactive({
      name: 'John'
    })
  })
})

// ✅ Good
const state = reactive({
  user: {
    profile: {
      name: 'John'
    }
  }
})

Enter fullscreen mode Exit fullscreen mode

Integration with TypeScript

// Advanced TypeScript usage with reactive
import { reactive } from 'vue'

// Define complex types
interface User {
  id: number
  name: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

interface AppState {
  currentUser: User | null
  isAuthenticated: boolean
  settings: Map<string, any>
}

// Create type-safe reactive state
const state = reactive<AppState>({
  currentUser: null,
  isAuthenticated: false,
  settings: new Map()
})

// Type-safe methods
function updateUser(user: Partial<User>) {
  if (state.currentUser) {
    Object.assign(state.currentUser, user)
  }
}

// Readonly reactive state
import { readonly } from 'vue'
const readonlyState = readonly(state)
Enter fullscreen mode Exit fullscreen mode

Pinia Store Integration

Discover how to effectively integrate Pinia stores with Vue's Composition API. Learn best practices for state management and how to structure your stores for scalability.

Store Structure with Refs

export const useMyStore = defineStore('my-store', () => {
  // State
  const items = ref<Item[]>([])
  const isLoading = ref(false)
  const error = ref<Error | null>(null)

  // Computed
  const itemCount = computed(() => items.value.length)

  // Actions
  const fetchItems = async () => {
    isLoading.value = true
    try {
      items.value = await api.getItems()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  return {
    items,
    isLoading,
    error,
    itemCount,
    fetchItems
  }
})
Enter fullscreen mode Exit fullscreen mode

Composing Stores

export const useMainStore = defineStore('main-store', () => {
  // Using another store
  const otherStore = useOtherStore()

  // Watching other store's state
  watch(
    () => otherStore.someState,
    (newValue) => {
      // React to other store's changes
    }
  )
})
Enter fullscreen mode Exit fullscreen mode

Practical Examples

Real-world examples demonstrating how to implement common features and patterns in Vue applications. These examples show you how to put theory into practice.

Auto-refresh Implementation

export const useChatStore = defineStore('chat-store', () => {
  const messages = ref<Message[]>([])
  const refreshInterval = ref<number | null>(null)
  const isRefreshing = ref(false)

  // Watch for auto-refresh state
  watch(isRefreshing, (shouldRefresh) => {
    if (shouldRefresh) {
      startAutoRefresh()
    } else {
      stopAutoRefresh()
    }
  })

  const startAutoRefresh = () => {
    refreshInterval.value = window.setInterval(() => {
      fetchNewMessages()
    }, 5000)
  }

  const stopAutoRefresh = () => {
    if (refreshInterval.value) {
      clearInterval(refreshInterval.value)
      refreshInterval.value = null
    }
  }

  return {
    messages,
    isRefreshing,
    startAutoRefresh,
    stopAutoRefresh
  }
})
Enter fullscreen mode Exit fullscreen mode

Loading State Management

export const useDataStore = defineStore('data-store', () => {
  const data = ref<Data[]>([])
  const isLoading = ref(false)
  const error = ref<Error | null>(null)

  // Watch loading state for side effects
  watch(isLoading, (loading) => {
    if (loading) {
      // Show loading indicator
    } else {
      // Hide loading indicator
    }
  })

  // Watch for errors
  watch(error, (newError) => {
    if (newError) {
      // Handle error (show notification, etc.)
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

Common Gotchas

Avoid common mistakes and debugging headaches by learning about these frequently encountered issues and their solutions.

1. Ref Initialisation

// ❌ Bad
const data = ref()  // Type is 'any'

// ✅ Good
const data = ref<string[]>([])  // Explicitly typed
Enter fullscreen mode Exit fullscreen mode

2. Watch Cleanup

// ❌ Bad - No cleanup
watch(source, () => {
  const timer = setInterval(() => {}, 1000)
})

// ✅ Good - With cleanup
watch(source, () => {
  const timer = setInterval(() => {}, 1000)
  return () => clearInterval(timer)  // Cleanup function
})
Enter fullscreen mode Exit fullscreen mode

3. Computed vs Watch

// ❌ Bad - Using watch for derived state
watch(items, (newItems) => {
  itemCount.value = newItems.length
})

// ✅ Good - Using computed for derived state
const itemCount = computed(() => items.value.length)
Enter fullscreen mode Exit fullscreen mode

4. Store Organization

// ✅ Good store organization
export const useStore = defineStore('store', () => {
  // State refs
  const data = ref<Data[]>([])
  const isLoading = ref(false)

  // Computed properties
  const isEmpty = computed(() => data.value.length === 0)

  // Watchers
  watch(data, () => {
    // Handle data changes
  })

  // Actions
  const fetchData = async () => {
    // Implementation
  }

  // Return public interface
  return {
    data,
    isLoading,
    isEmpty,
    fetchData
  }
})
Enter fullscreen mode Exit fullscreen mode

Common Gotchas

  1. Forgetting .value
// ❌ Bad
const count = ref(0)
count++ // Won't work

// ✅ Good
count.value++
Enter fullscreen mode Exit fullscreen mode
  1. Watch Timing
// ❌ Bad - Might miss initial state
watch(source, () => {})

// ✅ Good - Catches initial state
watch(source, () => {}, { immediate: true })
Enter fullscreen mode Exit fullscreen mode
  1. Memory Leaks
// ❌ Bad - No cleanup
const store = useStore()
setInterval(() => {
  store.refresh()
}, 1000)

// ✅ Good - With cleanup
const intervalId = setInterval(() => {
  store.refresh()
}, 1000)
onBeforeUnmount(() => clearInterval(intervalId))
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

Take your Vue.js skills to the next level with advanced patterns and techniques for building complex, scalable applications.

Complex Component Communication

// Example of advanced component communication patterns
export function useComponentBridge() {
  const events = ref(new Map())

  function emit(event: string, data: any) {
    if (events.value.has(event)) {
      events.value.get(event).forEach((handler: Function) => handler(data))
    }
  }

  function on(event: string, handler: Function) {
    if (!events.value.has(event)) {
      events.value.set(event, new Set())
    }
    events.value.get(event).add(handler)

    return () => events.value.get(event).delete(handler)
  }

  return { emit, on }
}
Enter fullscreen mode Exit fullscreen mode

Testing Strategies

Learn how to effectively test your Vue components and stores using modern testing practices and tools.

Testing Composables

import { renderComposable } from '@testing-library/vue-composables'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  test('should increment counter', () => {
    const { result } = renderComposable(() => useCounter())

    expect(result.current.count.value).toBe(0)
    result.current.increment()
    expect(result.current.count.value).toBe(1)
  })
})
Enter fullscreen mode Exit fullscreen mode

Performance Optimization

Techniques and strategies for optimizing your Vue applications for better performance and user experience.

Computed Property Optimization

// Optimizing computed properties for better performance
const expensiveComputation = computed(() => {
  return memoize(() => {
    // Expensive calculation here
    return result
  })
})
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
ajinkyax
Ajinkya Borade

Posted on November 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related