April 9, 2025

Best Practices when using Pinia with Vue 3 and TypeScript

Best practices for using Pinia with Vue 3 and TypeScript, including composition API stores, type safety, and advanced state management patterns.

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
Pinia Vue 3 TypeScript State Management Composition API

Let's Build Something Amazing

Ready to discuss your next project? I'm always interested in tackling complex challenges and building innovative solutions that drive business growth.