Pinia 3 and TanStack Query 5 for Vue 3.6 — Modern State Management in the Vapor Mode Era 2026
Posted on: 4/17/2026 12:08:59 AM
Table of contents
- 1. Why State Management for Vue Is Changing in 2026
- 2. The Evolution — From Vuex 3 to Pinia 3 and TanStack Query 5
- 3. Classifying State — The Decision Map Before You Write a Store
- 4. Pinia 3 — Store Architecture and Practical Patterns
- 5. TanStack Query 5 — Managing Server State
- 6. Combining Pinia and TanStack Query — Clear Role Division
- 7. Vapor Mode — Exploiting Compile-Time Reactivity for Stores
- 8. Testing — Disciplined Testing of Stores and Queries
- 9. Migrating from Vuex to Pinia — A Practical Shortcut
- 10. Performance — Common Leaks and How to Plug Them
- 11. SSR and Hydration — Familiar Pitfalls
- 12. Checklist Before Merging a State-Touching PR
- 13. Conclusion — Vue State Management Has Matured, the Vue Way
1. Why State Management for Vue Is Changing in 2026
In the eight years since Vuex 3 arrived, Vue state management has cycled through three generations. Vuex 3 split state into static modules, used synchronous mutations, and asynchronous actions. Vuex 4 tried to reconcile with the Composition API but still carried the old boilerplate. Pinia debuted as an experimental library in 2019 and gradually became Vue's official store, inheriting every role of Vuex from Vue 3.4 onwards. In 2026, now that Vue 3.6 unlocks Vapor Mode — a reactivity system compiled into tight code that skips the Virtual DOM — the state model again needs to adjust to exploit the new performance.
At the same time, the frontend community has accepted a clear split that didn't exist five years ago: server state (data owned by the backend, with cache lifetimes, refreshes, and mutations) versus UI/client state (state that lives only in the browser session, not required to persist). Vue 3.6 in 2026 stops trying to jam both into a single library. Pinia 3 is tuned to specialise in UI state; TanStack Query 5 (formerly Vue Query) has become the default for server state. This article analyses how the two libraries collaborate, the modern store architectures, how to exploit Vapor Mode, and a production checklist.
The question that frames this whole article
When a Vue component needs data, three foundational questions must be answered before you write the first line: Is this piece of data the server's source of truth? Does it need to be shared across multiple components? Does its lifecycle end when a component unmounts, or does it follow the user session? Answering those three questions picks Pinia, TanStack Query, or plain ref in a setup script automatically.
2. The Evolution — From Vuex 3 to Pinia 3 and TanStack Query 5
Understanding this sequence explains why the Vue community split state into two categories, why Pinia beat Vuex, and why TanStack Query is not seen as a competitor to Pinia.
mutations and asynchronous actions. Beautiful in theory, but it produced a pile of files, and everything had to be declared up-front.setup function like a component, using ref, computed, watch directly. No more mutations, no more rigid nested modules.useSuspenseQuery, infinite queries with cursor and offset, and a new mutation observer. The Vue adapter uses primitive reactivity directly, with no extra proxy layer.3. Classifying State — The Decision Map Before You Write a Store
The most common mistake for newcomers is dumping everything into one massive store. In 2026, the community consensus is to split state into four clear groups and pick the right tool per group.
flowchart TD
A[Component needs data] --> B{Is the server the source of truth?}
B -- Yes --> C[TanStack Query]
B -- No --> D{Shared across multiple components?}
D -- No --> E[ref/reactive in setup]
D -- Yes --> F{Follows the user session?}
F -- Yes --> G[Pinia store]
F -- No --> H[provide/inject scoped]
G --> I{Needs cross-tab sync?}
I -- Yes --> J[Pinia + BroadcastChannel plugin]
I -- No --> K[Plain Pinia]
Figure 1: Decision tree for picking a Vue 3.6 state tool in 2026
3.1. Server state — data owned by the backend
Characteristics of this type: there is a source of truth outside the client (REST/GraphQL/RPC), it needs a cache to avoid duplicate requests, it needs refetch as conditions change, and it needs invalidation after mutations. This is TanStack Query's territory, not Pinia's. Jamming server state into Pinia usually leads to re-implementing built-in features: dedupe, retry, polling, optimistic update, garbage collection, focus refetch.
3.2. UI state — session-scoped state
Dark/light theme, display language, sidebar open or closed, table filter, the currently selected tab. This belongs in Pinia. It can persist across pages via pinia-plugin-persistedstate writing to localStorage or sessionStorage.
3.3. Form state — input entry state
Forms should not go into Pinia, and definitely not into TanStack Query. Use VeeValidate 4 or FormKit 1 to hold values, errors, touched, dirty. On submit, call mutate in TanStack Query to send to the server and invalidateQueries to refresh related data.
3.4. URL state — state in the query string
Filters, pagination, and search keywords are URL state. Put them in route.query via Vue Router 5 so they are bookmarkable, shareable, and survive back/forward. TanStack Query reads a computed wrapper around the route to key the cache correctly.
Warning: shoving server state into Pinia is silent tech debt
When you write a Pinia store with state.users, actions.fetchUsers, actions.createUser, actions.updateUser, you are rebuilding TanStack Query by hand. In a few months you'll need to track isLoading, isError, cache TTL, dedupe when 5 components call at once. Every home-grown feature is a bug waiting to happen. Put server state in the right place from day one.
4. Pinia 3 — Store Architecture and Practical Patterns
4.1. Two syntaxes — Options and Setup
Pinia supports two store declarations. Options style resembles Vue 2 for people familiar with Vuex. Setup style looks like the Composition API and is the recommended default in 2026: concise, customisable, and easy to test.
// stores/auth.js — Setup style (recommended)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(null)
const isAuthenticated = computed(() => !!token.value)
const initials = computed(() => {
if (!user.value?.name) return ''
return user.value.name.split(' ').map(p => p[0]).join('').toUpperCase()
})
function setSession(payload) {
user.value = payload.user
token.value = payload.token
}
function logout() {
user.value = null
token.value = null
}
return { user, token, isAuthenticated, initials, setSession, logout }
})
4.2. Composing stores — how Pinia replaces nested modules
Vuex used nested modules to organise large state. Pinia drops that idea entirely — instead, a store can use another store internally. Organise horizontally by domain, not vertically by tree.
// stores/cart.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', () => {
const auth = useAuthStore()
const items = ref([])
const subtotal = computed(() =>
items.value.reduce((s, i) => s + i.price * i.qty, 0)
)
const tax = computed(() =>
auth.user?.country === 'VN' ? subtotal.value * 0.08 : 0
)
const total = computed(() => subtotal.value + tax.value)
return { items, subtotal, tax, total }
})
4.3. Plugin system — extend every store sanely
A Pinia plugin is a function that receives { store } and can add properties, observe mutations, and intercept actions. Three widely used plugins in 2026:
- pinia-plugin-persistedstate: automatically writes state to localStorage/sessionStorage/cookies, with per-store configuration.
- pinia-undo: adds
store.undo()andstore.redo()for stores that need history. - pinia-shared-state: uses the BroadcastChannel API to sync state across same-origin tabs (very handy for auth, theme).
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// stores/preferences.js
export const usePreferences = defineStore('prefs', () => {
const theme = ref('light')
const locale = ref('vi')
return { theme, locale }
}, {
persist: { storage: localStorage, paths: ['theme', 'locale'] }
})
4.4. SSR and Pinia with Nuxt 4
Nuxt 4 creates a fresh Pinia per request and serialises state into the HTML payload. Avoid accessing window, document, or localStorage in a store's setup function — they don't exist on the server. Instead, use onMounted on the component or wrap in if (process.client).
5. TanStack Query 5 — Managing Server State
5.1. Four core concepts
You don't need to memorise hundreds of options — master four: query key (the cache identifier), query function (how to fetch), mutation (how to change the server), and invalidation (marking caches as stale). Everything else is a variation.
// composables/useUsers.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
export function useUsers(filter) {
return useQuery({
queryKey: ['users', filter],
queryFn: ({ signal }) => fetch(`/api/users?role=${filter.value}`, { signal })
.then(r => r.json()),
staleTime: 60_000,
placeholderData: (prev) => prev
})
}
export function useCreateUser() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload) => fetch('/api/users', {
method: 'POST', body: JSON.stringify(payload)
}).then(r => r.json()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] })
})
}
5.2. Configuring QueryClient for production
TanStack Query's defaults are conservative: refetch on window focus, 3 retries, no staleTime. Production usually needs tuning.
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // data is considered fresh for 30s
gcTime: 5 * 60_000, // garbage-collect after 5 minutes unused
refetchOnWindowFocus: false,
retry: (failureCount, err) => err.status >= 500 && failureCount < 2,
networkMode: 'offlineFirst'
},
mutations: {
retry: false,
networkMode: 'always'
}
}
})
app.use(VueQueryPlugin, { queryClient })
5.3. Optimistic updates — mutations that feel instantaneous
For actions highly likely to succeed (like, follow, edit comment), update the UI immediately and reconcile with the server. If the server rejects, roll back. TanStack Query standardises this via onMutate, onError, onSettled.
const toggleFavorite = useMutation({
mutationFn: (postId) => api.toggleFavorite(postId),
onMutate: async (postId) => {
await qc.cancelQueries({ queryKey: ['posts'] })
const prev = qc.getQueryData(['posts'])
qc.setQueryData(['posts'], (old) =>
old.map(p => p.id === postId
? { ...p, favorited: !p.favorited }
: p)
)
return { prev }
},
onError: (err, postId, context) => {
qc.setQueryData(['posts'], context.prev)
},
onSettled: () => qc.invalidateQueries({ queryKey: ['posts'] })
})
6. Combining Pinia and TanStack Query — Clear Role Division
This is the practical section that decides the quality of your architecture. The rule is simple: Pinia holds information about who I am and what I'm doing (auth, theme, filter); TanStack Query holds what the server knows.
flowchart LR
U[Component] --> P[Pinia: auth, prefs, filter]
U --> Q[TanStack Query: users, posts, orders]
P --> F[filter ref]
F --> QK[queryKey]
QK --> Q
Q --> API[REST/GraphQL]
M[Mutation] --> API
M --> INV[invalidateQueries]
INV --> Q
Figure 2: Pinia feeds the query key, TanStack Query feeds the actual data
// composables/useFilteredOrders.js
import { computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useOrderFilter } from '@/stores/order-filter'
export function useFilteredOrders() {
const filter = useOrderFilter() // Pinia store holding the UI filter
const queryKey = computed(() => ['orders', {
status: filter.status,
from: filter.from,
to: filter.to,
page: filter.page
}])
return useQuery({
queryKey,
queryFn: () => api.getOrders(filter.toQuery()),
placeholderData: (prev) => prev,
staleTime: 10_000
})
}
When the user changes the filter in Pinia, the query key changes and TanStack Query refetches automatically. Thanks to placeholderData holding the old data, the UI doesn't snap back to a loading state between pages. This is the standard pattern for admin pages with large data tables.
7. Vapor Mode — Exploiting Compile-Time Reactivity for Stores
Vue 3.6 Vapor Mode compiles templates directly into tight DOM-manipulation code without the Virtual DOM. Pinia 3 works with both Vapor and non-Vapor, but there are a few things to watch to avoid anti-patterns.
| Aspect | Standard Vue 3 | Vue 3.6 Vapor |
|---|---|---|
| DOM updates | Virtual DOM diff and patch | Direct text/attribute binding updates |
| Reactivity | Proxy + render-time dep tracking | Effects compiled into code specific to each binding |
| Small component bundle | ~12 KB runtime | ~5 KB runtime for a Vapor subtree |
| Pinia store | Normal ref + computed | Unchanged, no porting needed |
| TanStack Query | Default Vue adapter | Vapor adapter trims tracking cost |
| Devtools | Component tree + state | Binding + effect tree |
Tips for mixing Vapor and non-Vapor in the same app
Vue 3.6 lets a component opt into Vapor with the vapor directive in <script setup>. Pinia stores aren't affected — the same store works for both. Start migrating leaf components with many simple bindings (badges, counts, theme switchers) to Vapor for quick wins.
8. Testing — Disciplined Testing of Stores and Queries
8.1. Testing a Pinia store
Pinia provides setActivePinia(createPinia()) to reinitialise the store for each test. Vitest is the most common combo.
// stores/cart.spec.js
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCartStore } from './cart'
describe('cart store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('computes subtotal correctly for multiple items', () => {
const cart = useCartStore()
cart.items = [
{ price: 100, qty: 2 },
{ price: 50, qty: 3 }
]
expect(cart.subtotal).toBe(350)
})
})
8.2. Testing TanStack Query
Create a fresh QueryClient per test, mock fetch, or use MSW (Mock Service Worker) to intercept at the network layer. Don't test cache logic — TanStack Query has already tested it for you.
import { mount } from '@vue/test-utils'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
function createWrapper(component) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
return mount(component, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] }
})
}
9. Migrating from Vuex to Pinia — A Practical Shortcut
Plenty of legacy Vue 2/3 projects still run Vuex. The lowest-risk migration is a four-step process done in parallel, not big-bang.
- Install Pinia alongside Vuex. Both can coexist. Old components keep using
useStore, new ones move touseXxxStore. - Map each Vuex module to a Pinia store. State stays; getters become
computed; mutations fold straight into actions; actions keep their logic. Expect roughly 30% fewer lines than the Vuex original. - Replace component call sites branch by branch. Each PR migrates one module, with careful testing. Don't swap everything in a single commit.
- Remove Vuex once nothing imports it. Verify with
greporknip, then delete frompackage.json.
Special note for Nuxt 2 and older Nuxt 3
Nuxt 2 uses Vuex via the store/index.js convention. Nuxt 3+ only supports Pinia. A Nuxt 2 project must be upgraded first; cramming Pinia into Nuxt 2 runs into SSR serialisation issues. Reasonable route: Nuxt 2 → Nuxt Bridge → Nuxt 3.x → Nuxt 4.
10. Performance — Common Leaks and How to Plug Them
Pinia and TanStack Query are both well-optimised, but a few familiar performance traps still exist if you're careless.
10.1. Computeds depending on large array/object references
When a computed reads a large array and .map().filter() creates a new array on each invalidation, every downstream component re-renders. Split into smaller computeds or use shallowRef for arrays that don't need deep reactivity.
10.2. Mutations that don't invalidate the right query key
A very common mistake: onSuccess calls invalidateQueries({ queryKey: ['users'] }) but the actual query is ['users', { role: 'admin' }]. TanStack Query defaults to partial matching so it usually works, but if you need precision, use { exact: true }.
10.3. Not using placeholderData for pagination
When a user clicks page 2, the cache for page 1 is still there but page 2 hasn't been fetched. The UI defaults to a loading state. Use placeholderData: (prev) => prev to keep the old data while the new one fetches, producing a native-feeling smoothness.
10.4. Devtools leaking into production
Pinia devtools and TanStack Query devtools are disabled in production by default, but a wrong import can accidentally drag them into the bundle. Use conditional dynamic import guarded by import.meta.env.DEV.
11. SSR and Hydration — Familiar Pitfalls
With Nuxt 4 and Vite SSR, both Pinia and TanStack Query can serialise/deserialise state into the HTML payload so the client hydrates without refetching. Two things to remember:
- In Pinia, don't access
window/documentin the store setup function — useonMountedon the component when needed. - In TanStack Query, use
dehydrate(queryClient)on the server andhydrate(queryClient, state)on the client. Nuxt 4's@tanstack/vue-query/nuxtmodule automates the whole thing.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt', '@tanstack/vue-query/nuxt'],
vueQuery: {
queryClientConfig: {
defaultOptions: { queries: { staleTime: 30_000 } }
}
}
})
12. Checklist Before Merging a State-Touching PR
- Is this server state? If yes, use TanStack Query, not Pinia.
- Is the Pinia store shared across components? If not, put it inside the component with
ref/reactive. - Does the query key depend on a Pinia filter? Wrap it in a
computedso it reacts automatically. - Does the mutation call
invalidateQuerieson the right key on success? - Does the optimistic update roll back in
onError? - Is the Pinia store used in SSR? Avoid browser APIs in setup.
- Does persisted state exclude sensitive fields (token, password) from localStorage?
- Are devtools disabled in the production build?
- Do tests include
setActivePinia(createPinia())inbeforeEachto avoid leaking state? - Any Vuex modules still left to migrate? Use
knipto find stray imports.
13. Conclusion — Vue State Management Has Matured, the Vue Way
The lesson of 2026 isn't "Pinia is better than Vuex" or "TanStack Query is better than Pinia". The real lesson is: state comes in many flavours, each flavour needs the right tool, and the Vue ecosystem finally has the right tools. Pinia 3 specialises in UI state with lean syntax inheriting the Composition API's spirit. TanStack Query 5 specialises in server state with cache, mutation, and optimistic update refined across millions of React apps. Vue Router 5 holds URL state. VeeValidate or FormKit holds form state.
When these four layers each own their role, Vue code stops being tangled — no more Vuex boilerplate, no more hand-rolled caches per resource. Vapor Mode is just the performance cherry on top — correct architecture is the key. In 2026, a clean Vue codebase looks like this: short stores because TanStack Query handles server state, short components because stores replaced prop drilling, and most lines of code go to business logic rather than state plumbing.
Next step for a migrating team
If the codebase still has Vuex, start by moving a single module (auth, theme, or cart) to Pinia in one sprint. If Pinia is overloaded with server state, pick one resource (say users) and move it entirely to TanStack Query, then measure bundle size and line count reduction. The feeling of relief after the first conversion usually convinces the whole team to continue.
References
Claude Code Skills 2026 — Progressive Disclosure and How to Standardise Workflow for Engineering Teams
Blazor on .NET 10 in 2026 — Mastering Render Modes, Stream Rendering, and Enhanced Navigation for Full-stack C#
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.