Pinia is the recommended state management solution for Vue 3 applications. Here are best practices for using it effectively with TypeScript:
Summary
- Use composition API-style stores with TypeScript interfaces
- Define clear state interfaces and action return types
- Prefer computed getters over direct state access for derived data
- Organize stores by feature domain rather than data type
- Use proper TypeScript types throughout store definitions
Store Setup
Create stores in a ./stores
directory using the composition API syntax.
Define stores using defineStore('storeName', () => { ... })
pattern.
Import stores in components using const store = useStoreNameStore()
.
When accessing state across multiple stores, use storeToRefs()
to maintain reactivity when destructuring.
Organize related state and actions within feature-specific stores rather than creating one large global store.
Use $reset()
to reset store state to initial values during testing.
Regularly review stores to ensure they follow the single responsibility principle.
Keep API calls in separate composable functions rather than directly in stores to maintain clean separation of concerns.
TypeScript Integration
Define clear interfaces for store state and ensure type safety throughout Vue 3 projects with TypeScript.
Store Examples
Composition API Store Pattern
A well-structured user management store example:
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
}
interface UserState {
users: User[]
currentUser: User | null
loading: boolean
error: string | null
}
export const useUserStore = defineStore('user', () => {
// State
const state = reactive<UserState>({
users: [],
currentUser: null,
loading: false,
error: null
})
// Getters
const isAdmin = computed(() =>
state.currentUser?.role === 'admin'
)
const userCount = computed(() => state.users.length)
// Actions
async function fetchUsers(): Promise<void> {
state.loading = true
state.error = null
try {
const response = await userApi.getUsers()
state.users = response.data
} catch (error) {
state.error = error instanceof Error ? error.message : 'Unknown error'
} finally {
state.loading = false
}
}
return {
// State
...toRefs(state),
// Getters
isAdmin,
userCount,
// Actions
fetchUsers
}
})
This example demonstrates several best practices:
- Clear TypeScript interfaces provide excellent IntelliSense and compile-time error checking
- The composition API syntax with
defineStore()
offers better TypeScript inference than the options API - Import the store in components using
const userStore = useUserStore()
- API logic is kept in a separate
userApi
module rather than mixing HTTP calls directly in the store
Advanced State Management Pattern
A shopping cart store demonstrating advanced TypeScript patterns:
interface Product {
id: string
name: string
price: number
category: string
}
interface CartItem {
product: Product
quantity: number
}
interface CartState {
items: CartItem[]
discountCode: string | null
shippingAddress: Address | null
}
export const useCartStore = defineStore('cart', () => {
const state = reactive<CartState>({
items: [],
discountCode: null,
shippingAddress: null
})
// Computed getters for derived state
const itemCount = computed(() =>
state.items.reduce((sum, item) => sum + item.quantity, 0)
)
const subtotal = computed(() =>
state.items.reduce((sum, item) =>
sum + (item.product.price * item.quantity), 0
)
)
const isEmpty = computed(() => state.items.length === 0)
// Type-safe actions
function addItem(product: Product, quantity = 1): void {
const existingItem = state.items.find(item =>
item.product.id === product.id
)
if (existingItem) {
existingItem.quantity += quantity
} else {
state.items.push({ product, quantity })
}
}
function removeItem(productId: string): void {
const index = state.items.findIndex(item =>
item.product.id === productId
)
if (index > -1) {
state.items.splice(index, 1)
}
}
function updateQuantity(productId: string, quantity: number): void {
const item = state.items.find(item =>
item.product.id === productId
)
if (item && quantity > 0) {
item.quantity = quantity
} else if (item && quantity === 0) {
removeItem(productId)
}
}
function clearCart(): void {
state.items = []
state.discountCode = null
}
return {
// State (reactive refs)
items: toRef(state, 'items'),
discountCode: toRef(state, 'discountCode'),
shippingAddress: toRef(state, 'shippingAddress'),
// Getters
itemCount,
subtotal,
isEmpty,
// Actions
addItem,
removeItem,
updateQuantity,
clearCart
}
})
Key patterns demonstrated:
- Computed properties for derived state like totals and counts ensure values are cached and only recalculated when dependencies change
- Type annotations for all action parameters catch type errors at compile time when calling store actions from components
toRef()
exposes individual state properties as refs while maintaining reactivity, allowing components to destructure specific properties
Async Actions and Error Handling Pattern
Proper async handling with TypeScript:
interface ApiResponse<T> {
data: T
status: number
message?: string
}
interface PostsState {
posts: Post[]
selectedPost: Post | null
loading: boolean
error: string | null
lastFetched: Date | null
}
export const usePostsStore = defineStore('posts', () => {
const state = reactive<PostsState>({
posts: [],
selectedPost: null,
loading: false,
error: null,
lastFetched: null
})
// Getters with proper typing
const publishedPosts = computed((): Post[] =>
state.posts.filter(post => post.status === 'published')
)
const isStale = computed((): boolean => {
if (!state.lastFetched) return true
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
return state.lastFetched < fiveMinutesAgo
})
// Async actions with proper error handling
async function fetchPosts(force = false): Promise<void> {
if (!force && !isStale.value) return
state.loading = true
state.error = null
try {
const response: ApiResponse<Post[]> = await postsApi.getPosts()
state.posts = response.data
state.lastFetched = new Date()
} catch (error) {
state.error = error instanceof Error
? error.message
: 'Failed to fetch posts'
console.error('Error fetching posts:', error)
} finally {
state.loading = false
}
}
async function createPost(postData: Omit<Post, 'id' | 'createdAt'>): Promise<Post | null> {
state.loading = true
state.error = null
try {
const response: ApiResponse<Post> = await postsApi.createPost(postData)
const newPost = response.data
state.posts.unshift(newPost)
return newPost
} catch (error) {
state.error = error instanceof Error
? error.message
: 'Failed to create post'
return null
} finally {
state.loading = false
}
}
return {
// State
...toRefs(state),
// Getters
publishedPosts,
isStale,
// Actions
fetchPosts,
createPost
}
})
Best practices demonstrated in this pattern:
- Handle loading states consistently across all async actions for predictable UX feedback
- Use proper TypeScript generics for API responses to ensure type safety and catch mismatched response types
- Implement error boundaries in stores rather than letting errors bubble up unhandled for better user experience
- Use
Omit
utility types for action parameters to exclude auto-generated fields like IDs and timestamps - Track data freshness and implement cache invalidation logic to prevent unnecessary API calls while ensuring data stays current