Introduction
After 16 days of building Vue.js and Nuxt applications, you've likely hit the point where a single store file becomes unwieldy. As your team adds features and the codebase grows, the way you structure Pinia stores determines whether your state management scales or becomes a maintenance nightmare. We're diving into organizing Pinia stores: real-world lessons and examples, drawing from production applications across e-commerce, CRM, and SaaS platforms where we've learned these patterns the hard way.
One store per domain
The most common mistake we see is creating a monolithic store that handles everything—user auth, products, orders, notifications, UI state. What starts as convenience becomes chaos. The solution is straightforward: organize stores by business domain.
In an e-commerce application, you'd create separate stores for:
auth– login, tokens, user identitycart– items, quantities, pricingproducts– catalog, filtering, searchorders– purchase history, order detailsui– modals, toast notifications, sidebar state
Each store has a single responsibility. When a developer needs to modify cart logic, they know exactly where to look. When tests run, they can mock a specific domain without touching unrelated state.
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const itemCount = computed(() => items.value.length)
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const addItem = (product: Product) => {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
return { items, itemCount, total, addItem }
})
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(null)
const login = async (email: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await response.json()
token.value = data.token
user.value = data.user
}
return { user, token, login }
})
This structure scales. When a team of five engineers works on the same codebase, domain boundaries reduce conflicts and make code reviews faster.
Keep server data out or use a query library
Here's where many teams struggle: should Pinia manage server-fetched data, or should you use a query library like TanStack Query (React Query)? We've learned that Pinia excels at UI state and derived data, not server state caching.
The distinction matters. UI state is local to your app—whether a modal is open, which tab is active, the user's sorting preference. Server state is fetched from an API and shared across requests, users, and sessions. Pinia can handle simple server state, but as complexity grows (cache invalidation, background refetching, optimistic updates), you'll want a dedicated solution.
For smaller projects, keep Pinia pure and fetch data in components or composables:
// stores/ui.ts – UI state only
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const sidebarOpen = ref(false)
const notificationStack = ref([])
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
return { sidebarOpen, toggleSidebar, notificationStack }
})
// composables/useProducts.ts – Server state fetching
import { ref } from 'vue'
export const useProducts = () => {
const products = ref([])
const loading = ref(false)
const error = ref(null)
const fetchProducts = async (filters?: FilterOptions) => {
loading.value = true
error.value = null
try {
const params = new URLSearchParams(filters)
const response = await fetch(`/api/products?${params}`)
products.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch'
} finally {
loading.value = false
}
}
return { products, loading, error, fetchProducts }
}
For larger applications, especially those with complex caching needs, use TanStack Query or a similar library. It handles deduplication, background refetching, and stale-while-revalidate patterns that Pinia wasn't designed for.
If you do cache server data in Pinia, add clear invalidation logic. A common pattern is a mutate action that updates the cached data and resets it when mutations occur:
// stores/products.ts – with server state caching
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useProductsStore = defineStore('products', () => {
const items = ref([])
const loaded = ref(false)
const fetch = async () => {
if (loaded.value) return // Don't refetch if already loaded
const response = await fetch('/api/products')
items.value = await response.json()
loaded.value = true
}
const createProduct = async (product: Omit) => {
const response = await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(product)
})
const newProduct = await response.json()
items.value.push(newProduct)
return newProduct
}
const invalidate = () => {
loaded.value = false
}
return { items, fetch, createProduct, invalidate }
})
Actions for async work
Pinia actions are where business logic lives. They're the place to handle API calls, validation, and orchestration across your state. Using them properly keeps your components clean and your logic testable.
An action should represent a user intent or business operation—"add to cart," "submit form," "logout." Each action is a single, cohesive operation. If you find yourself calling multiple actions in sequence from a component, that's a sign you should extract a single action that does the orchestration.
// stores/order.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useCartStore } from './cart'
import { useAuthStore } from './auth'
export const useOrderStore = defineStore('order', () => {
const currentOrder = ref(null)
const submitting = ref(false)
const error = ref(null)
const submitOrder = async (shippingAddress: Address) => {
const cart = useCartStore()
const auth = useAuthStore()
if (!auth.user) {
throw new Error('Not authenticated')
}
submitting.value = true
error.value = null
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Authorization': `Bearer ${auth.token}` },
body: JSON.stringify({
items: cart.items,
shippingAddress,
userId: auth.user.id
})
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Order submission failed')
}
currentOrder.value = await response.json()
cart.items = [] // Clear cart after successful order
return currentOrder.value
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
throw e
} finally {
submitting.value = false
}
}
return { currentOrder, submitting, error, submitOrder }
})
// In a component, you call one action that handles the orchestration
const handleCheckout = async () => {
try {
const order = await orderStore.submitOrder(shippingForm.value)
router.push(`/orders/${order.id}`)
} catch (e) {
// Error is already in orderStore.error
}
}
Notice how the action loads both the cart and auth stores, validates context, makes the API call, updates related state, and provides feedback through submitting and error` refs. The component doesn't need to orchestrate these steps—it just calls one action and reacts to the result.
Compose stores carefully
As your application grows, stores will depend on each other. The user's profile data might affect what products they can view. The current organization's settings determine feature flags. This is where composition becomes tricky.
The safe pattern is to call other stores' actions inside your actions, not in getters or top-level code. This keeps dependencies explicit and avoids circular references or initialization order issues.
// stores/featureFlags.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useFlagStore = defineStore('flags', () => {
const flags = ref>({})
const fetchFlags = async (organizationId: string) => {
const response = await fetch(`/api/flags/${organizationId}`)
flags.value = await response.json()
}
const isEnabled = (flagName: string) => flags.value[flagName] ?? false
return { flags, fetchFlags, isEnabled }
})
// stores/products.ts – depends on feature flags
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useFlagStore } from './featureFlags'
export const useProductsStore = defineStore('products', () => {
const items = ref([])
const fetch = async (organizationId: string) => {
const flags = useFlagStore()
await flags.fetchFlags(organizationId) // Call dependent store's action
const params = new URLSearchParams({
includeArchived: flags.isEnabled('show_archived_products') ? 'true' : 'false'
})
const response = await fetch(`/api/products?${params}`)
items.value = await response.json()
}
return { items, fetch }
})
Avoid using computed properties that depend on other stores' state directly. If you need derived data across stores, compute it in a component or composable where the dependency is explicit:
// ❌ Avoid this pattern
const useOrdersStore = defineStore('orders', () => {
const orders = ref([])
// Don't access another store's state in a getter
const userOrders = computed(() => {
const auth = useAuthStore()
return orders.value.filter(o => o.userId === auth.user?.id)
})
})
// ✅ Better: compute in component or composable
const useUserOrders = () => {
const orders = useOrdersStore()
const auth = useAuthStore()
return computed(() => {
return orders.items.filter(o => o.userId === auth.user?.id)
})
}
Common mistakes
- Mixing UI and server state: Storing API responses in the same module as modal visibility flags makes testing harder and muddies responsibilities.
- Circular store dependencies: Store A calls Store B, and Store B calls Store A. Break these by moving shared logic into a third store or a composable.
- Complex getters: If a getter fetches data or calls another store's method, move that logic to an action. Getters should be synchronous and deterministic.
- Not validating in actions: Validation happens in components, but re-validate in actions. The API won't trust the frontend, so your action should check preconditions anyway.
- Storing UI state globally that should be local: A form's draft state, a dropdown's open/closed flag—these often belong in component state, not Pinia. Only lift to Pinia if multiple components need it.
Key takeaways
- Structure stores by business domain, not by data type. One store per conceptual area (cart, auth, products) makes navigation and testing straightforward.
- Keep Pinia for UI state and derived data. For server-fetched data at scale, use a query library or clear invalidation patterns.
- Put async work in actions. Actions orchestrate API calls, validate context, update state, and provide feedback. Components call actions, not the other way around.
- Compose stores cautiously. Call other stores' actions from within actions. Avoid complex getters that access other stores. Make dependencies explicit.
- Don't hoist all state. Component-local state is simpler and faster than Pinia. Use stores when state is shared, persistent, or needs complex async management.






Comments
Loading comments…