Contents
In Vue 3, keep most state as close as possible to where it is used: inside a component or in a composable. Pinia is for truly shared client state that outlives a single screen: the current user, the cart, interface settings. Server data is better loaded with caching tools than put into a store by hand.
Here are the kinds of state, when composables are enough, when Pinia is needed and how to avoid trouble with server-side rendering.
Which kinds of state does a frontend app have?
Almost every state problem starts with storing different kinds of data the same way. It helps to separate them by origin and lifetime from the start.
| Kind of state | Example | Where to keep it |
|---|---|---|
| Local | Whether a dropdown is open, a field's value | Inside the component |
| Form state | A multi-step order draft | A composable or wrapper component |
| Server data | Catalogue, list of orders | Cached fetching: useFetch, TanStack Query |
| Global client state | Current user, cart, theme | Pinia |
| URL state | Filters, page, sorting | The URL, so it can be shared as a link |
When are composables enough?
A composable is a function that uses Vue's reactivity and returns state and methods. It is enough when logic is reused across components but each component needs its own copy of the state: pagination, form handling, a timer, a file upload.
export function usePagination(total: Ref<number>, perPage = 20) {
const page = ref(1)
const pages = computed(() => Math.ceil(total.value / perPage))
const next = () => { if (page.value < pages.value) page.value++ }
const prev = () => { if (page.value > 1) page.value-- }
return { page, pages, next, prev }
}
A composable can be made shared by moving the ref outside the function, but that easily leaks state between users during server-side rendering. Pinia is safer for shared state.
Photo: Unsplash
When do you need Pinia?
Pinia is needed when the same client state is used by different parts of the app, lives longer than one screen and its changes are worth tracking. Pinia provides a clear structure, devtools with change history, typing and correct SSR behaviour.
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0))
function add(product: Product) {
const item = items.value.find(i => i.id === product.id)
item ? item.qty++ : items.value.push({ ...product, qty: 1 })
}
return { items, total, add }
})
Typical store candidates:
- the signed-in user and their permissions;
- the cart and favourites;
- interface settings: theme, language, collapsed menu;
- state synced across tabs or with local storage.
Why not keep server data in a store?
Loading data into a store by hand means deciding yourself when to refresh it, how to show loading and errors, how not to fetch the same thing twice and how to drop stale data. Caching data-fetching tools do this out of the box.
- in Nuxt —
useFetchanduseAsyncData: data loads on the server and reaches the browser without a second request; - in plain Vue — TanStack Query: caching, retries, background refresh, invalidation;
- only what the user changed on the client and has not yet saved goes into a store.
On the API the frontend talks to, see how to design a REST API.
How do you avoid SSR problems?
With server-side rendering one server application serves many users. If state is declared at module level, one user's data can end up in another user's page. Pinia and Nuxt's built-in tools create separate state per request.
- do not declare reactive state at module level outside stores and
useState; - do not touch
windoworlocalStorageduring server rendering; - do not put into a store anything that must not end up in the page HTML — secrets or other users' data;
- make sure server and client state match, or you will get hydration errors.
More on Nuxt in Nuxt vs Next.js and Vue vs React, and on typing in TypeScript on the frontend.
In short
- Keep state as close as possible to where it is used.
- Composables are for reusable logic with per-component state.
- Pinia is for shared client state: user, cart, settings.
- Load server data with caching tools instead of copying it into a store.
- With SSR, avoid module-level state and browser APIs on the server.
FAQ
Pinia or Vuex?
Pinia for new projects: it is officially recommended by the Vue team, simpler, better typed and built for the Composition API. Vuex is maintained for existing projects but gets no new features.
Should server data live in Pinia?
Better use data-fetching tools with caching — useFetch and useAsyncData in Nuxt or libraries like TanStack Query. They handle refreshing, retries and staleness for you.
How do you test Pinia stores?
Stores are tested like ordinary modules: create a fresh Pinia instance per test, call actions and check state. For components there is a utility that replaces the store with a testing one.



