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. 1. Why State Management for Vue Is Changing in 2026
    1. The question that frames this whole article
  2. 2. The Evolution — From Vuex 3 to Pinia 3 and TanStack Query 5
  3. 3. Classifying State — The Decision Map Before You Write a Store
    1. 3.1. Server state — data owned by the backend
    2. 3.2. UI state — session-scoped state
    3. 3.3. Form state — input entry state
    4. 3.4. URL state — state in the query string
      1. Warning: shoving server state into Pinia is silent tech debt
  4. 4. Pinia 3 — Store Architecture and Practical Patterns
    1. 4.1. Two syntaxes — Options and Setup
    2. 4.2. Composing stores — how Pinia replaces nested modules
    3. 4.3. Plugin system — extend every store sanely
    4. 4.4. SSR and Pinia with Nuxt 4
  5. 5. TanStack Query 5 — Managing Server State
    1. 5.1. Four core concepts
    2. 5.2. Configuring QueryClient for production
    3. 5.3. Optimistic updates — mutations that feel instantaneous
  6. 6. Combining Pinia and TanStack Query — Clear Role Division
  7. 7. Vapor Mode — Exploiting Compile-Time Reactivity for Stores
    1. Tips for mixing Vapor and non-Vapor in the same app
  8. 8. Testing — Disciplined Testing of Stores and Queries
    1. 8.1. Testing a Pinia store
    2. 8.2. Testing TanStack Query
  9. 9. Migrating from Vuex to Pinia — A Practical Shortcut
    1. Special note for Nuxt 2 and older Nuxt 3
  10. 10. Performance — Common Leaks and How to Plug Them
    1. 10.1. Computeds depending on large array/object references
    2. 10.2. Mutations that don't invalidate the right query key
    3. 10.3. Not using placeholderData for pagination
    4. 10.4. Devtools leaking into production
  11. 11. SSR and Hydration — Familiar Pitfalls
  12. 12. Checklist Before Merging a State-Touching PR
  13. 13. Conclusion — Vue State Management Has Matured, the Vue Way
    1. Next step for a migrating team
    2. References

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.

3.0Pinia 2026 release with HMR plugin and new devtools
5.xTanStack Query with Vapor Mode support and Suspense API
3.6Vue 3.6 unlocks opt-in Vapor Mode for components and stores
~40%Bundle savings when dropping Vuex and enabling Vapor for lightweight stores

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.

2017 — Vuex 2 and the Flux model
Vuex 2 borrowed Redux/Flux ideas: state as an immutable tree, changes through synchronous mutations and asynchronous actions. Beautiful in theory, but it produced a pile of files, and everything had to be declared up-front.
2019 — Composition API and experimental Pinia
Eduardo San Martin Morote wrote Pinia as an RFC proposing a lighter store for Vue 3. Each store is a setup function like a component, using ref, computed, watch directly. No more mutations, no more rigid nested modules.
2021 — Vue Query (Tanner Linsley + Damian Osipiuk)
React Query had already proven that server state needs its own library with cache, refetch, deduplication, retry, mutation, optimistic update. Vue Query was born, then merged into TanStack Query so the core could be shared with React, Solid, and Svelte.
2023 — Pinia officially replaces Vuex
The Vue Core team announced Pinia as the official store for Vue, with a migration guide. Vuex 4 gained no new features — maintenance only for legacy projects. Pinia has built-in devtools, time travel, and a full plugin system.
2024 — TanStack Query 5 and first-class Suspense
TanStack Query 5 rewrote its core, adding useSuspenseQuery, infinite queries with cursor and offset, and a new mutation observer. The Vue adapter uses primitive reactivity directly, with no extra proxy layer.
2026 — Vue 3.6 Vapor and Pinia 3
Pinia 3 is compatible with both standard Vue 3 and Vapor Mode. The new devtools support tracing primitive reactivity. TanStack Query ships a Vapor-aware adapter that cuts overhead for components that skip the Virtual DOM.

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() and store.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.

  1. Install Pinia alongside Vuex. Both can coexist. Old components keep using useStore, new ones move to useXxxStore.
  2. 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.
  3. Replace component call sites branch by branch. Each PR migrates one module, with careful testing. Don't swap everything in a single commit.
  4. Remove Vuex once nothing imports it. Verify with grep or knip, then delete from package.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/document in the store setup function — use onMounted on the component when needed.
  • In TanStack Query, use dehydrate(queryClient) on the server and hydrate(queryClient, state) on the client. Nuxt 4's @tanstack/vue-query/nuxt module 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 computed so it reacts automatically.
  • Does the mutation call invalidateQueries on 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()) in beforeEach to avoid leaking state?
  • Any Vuex modules still left to migrate? Use knip to 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