Supercharged Vue 3.0 Reactivity + Pinia Stores
Ajinkya Borade
Posted on November 19, 2024
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
- Ref and Reactive References
- Watch and Reactivity
- Composables
- Vue Reactivity API (reactive vs ref)
- Pinia Store Integration
- Practical Examples
- Best Practices
- Common Gotchas
- Advanced Patterns
- Testing Strategies
- 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
}
})
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)
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)
})
})
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
}
)
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
}
}
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 }
}
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
}
}
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
}
}
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
}
}
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>
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
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++
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
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)
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
}
}
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'
}
}
})
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)
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
}
})
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
}
)
})
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
}
})
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.)
}
})
})
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
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
})
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)
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
}
})
Common Gotchas
-
Forgetting
.value
// ❌ Bad
const count = ref(0)
count++ // Won't work
// ✅ Good
count.value++
- Watch Timing
// ❌ Bad - Might miss initial state
watch(source, () => {})
// ✅ Good - Catches initial state
watch(source, () => {}, { immediate: true })
- Memory Leaks
// ❌ Bad - No cleanup
const store = useStore()
setInterval(() => {
store.refresh()
}, 1000)
// ✅ Good - With cleanup
const intervalId = setInterval(() => {
store.refresh()
}, 1000)
onBeforeUnmount(() => clearInterval(intervalId))
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 }
}
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)
})
})
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
})
})
Posted on November 19, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.