The decision framework that ends the debate — local state, shared composable state, Pinia store, and server state with TanStack Query each solve a different problem. Here’s the exact diagram, the exact rules, and the three anti-patterns that appear when teams pick the wrong tool for each job.
The state management debate in Vue 3 has been running for four years and still produces the same argument in every new project: “should we put this in Pinia?” The argument exists because the question is wrong. Pinia, composables, local reactive state, and TanStack Query don’t compete. They solve different problems. Picking between them is the same kind of decision as picking between a database and a cache — the answer depends on what the state is, how many places need it, and whether it originates on a server.
This post is the decision framework that ends the argument, with the anti-patterns that show up when each rule is broken.
The Four Categories of State
Before the decision tree: state in a Vue 3 application falls into exactly four categories. Understanding which category a piece of state belongs to determines which tool is correct — before any other consideration.
Category 1: UI State
What: state that only affects the component it lives in
Examples: is the modal open, which tab is active, is a dropdown expanded,
is this form field focused, what's in a text input before submit
Owner: the component itself
Tool: ref() / reactive() in <script setup>
Category 2: Shared Transient State
What: state shared between components in the same feature or route,
that resets when the user leaves
Examples: filters applied to a table, the selected row in a list,
step number in a multi-step wizard, which items are selected
in a batch operation
Owner: a composable called by multiple components
Tool: composable with shared ref (module-level, not per-instance)
Category 3: Global Application State
What: state shared across the entire application,
that persists across navigation
Examples: authenticated user, shopping cart, notification list,
feature flags, current tenant in a multi-tenant SaaS
Owner: Pinia store
Tool: Pinia
Category 4: Server State
What: state that originates on a server and needs to be fetched,
cached, synchronized, and invalidated
Examples: API response data, paginated lists, search results,
anything that could be stale
Owner: the server (local state is a cache of it)
Tool: TanStack Query (or composable with fetch, for simpler cases)
The categories are not about the technical shape of the state — whether it’s a boolean, an object, or an array. They’re about the state’s scope and origin. A boolean that represents “is the sidebar open” belongs in Category 1 even though it’s used in three components (lift the ref to a composable or Pinia only when required). A boolean that represents “is this user authenticated” belongs in Category 3 even though it’s a simple boolean.
The Decision Tree
Is this state derived entirely from props or other local state?
→ Yes: computed() — not a state tool, just a derived value
→ No: continue
Does this state originate on a server (fetched from an API)?
→ Yes: TanStack Query (or reactive fetch composable for simpler cases)
→ No: continue
Is this state used by more than one component?
→ No: ref() / reactive() in <script setup> (local state)
→ Yes: continue
Does this state need to persist across route navigation?
→ No: shared composable (module-level ref exported from a composable)
→ Yes: Pinia store
The tree has four exits. Most state decisions resolve at the first or second exit. The majority of state in most Vue applications is either derived (computed) or server-originated (TanStack Query). The remaining local and global state splits at the navigation boundary.
Category 1: Local State — ref() and reactive()
Local state is the most common and most underused category. Developers who come from Vuex or who learned Vue when Vuex was the default solution reach for Pinia (or previously Vuex) for state that should never leave the component.
<!-- ✅ Local state — this is always correct here -->
<script setup lang="ts">
import { ref } from 'vue'
// UI state — belongs here, not in Pinia
const isDropdownOpen = ref(false)
const activeTab = ref<'overview' | 'details' | 'activity'>('overview')
const searchQuery = ref('')
const selectedItems = ref<number[]>([])
// Local form state before submission
const form = reactive({
name: '',
email: '',
})
function toggleDropdown() {
isDropdownOpen.value = !isDropdownOpen.value
}
function selectTab(tab: typeof activeTab.value) {
activeTab.value = tab
}
</script>
The test for “should this be local?”: if you deleted this component, would any other component need to know this state existed? If no: it’s local. isDropdownOpen for a dropdown component — gone with the component. activeTab for a tab panel — gone with the component. These are not candidates for Pinia or a shared composable.
The anti-pattern: Pinia for UI state
// ❌ UI state in Pinia — the most common overengineering pattern
export const useUiStore = defineStore('ui', {
state: () => ({
isModalOpen: false,
activeTab: 'overview',
sidebarExpanded: true,
selectedRowId: null as number | null,
dropdownOpenId: null as string | null,
}),
actions: {
openModal() { this.isModalOpen = true },
closeModal() { this.isModalOpen = false },
setActiveTab(tab: string) { this.activeTab = tab },
toggleSidebar() { this.sidebarExpanded = !this.sidebarExpanded },
},
})
This Pinia store will eventually contain 30 boolean flags and 20 actions, all of which could be local ref() values in the components that use them. The store grows by accretion: each developer adds one “convenient” piece of UI state to avoid passing props, and six months later the store is an undifferentiated collection of every piece of boolean state in the application.
The problem isn’t Pinia — it’s that this state has no reason to be global. The modal’s open state doesn’t need to survive navigation. No other route needs to know which tab is active in a component that isn’t rendered. Putting this in Pinia gives it a lifetime and a global presence it doesn’t need, and makes the store harder to understand for everyone who maintains it.
Category 2: Shared Transient State — Composables
When state needs to be shared between components but doesn’t need to survive navigation, a composable with module-level (singleton) state is correct.
The key distinction between a singleton composable and a per-instance composable:
// Per-instance composable — each component that calls this gets its own state
// The ref() is inside the function, created fresh on each call
export function useCounter() {
const count = ref(0) // ← new ref for each call
return { count, increment: () => count.value++ }
}
// Singleton composable — all components share the same state
// The ref() is outside the function, created once at module level
const count = ref(0) // ← shared across all callers
export function useSharedCounter() {
return { count, increment: () => count.value++ }
}
The singleton pattern without realising it is one of the most common state bugs in Vue 3 — a developer intends to share state between components, creates a module-level ref, but then wonders why resetting state in one component resets it for every user of the composable. Conversely: a developer intends the composable to be per-instance (each component has its own state), but puts the ref at the module level and can’t understand why changing state in one component changes it in all.
Real example: shared filter state for a table that appears in two sibling components
// composables/useProjectFilters.ts
// Module-level: shared between all components that import this
const filters = reactive({
status: null as string | null,
ownerId: null as number | null,
dateFrom: null as string | null,
dateTo: null as string | null,
})
const sortBy = ref<'name' | 'createdAt' | 'updatedAt'>('createdAt')
const sortDirection = ref<'asc' | 'desc'>('desc')
export function useProjectFilters() {
function resetFilters() {
Object.assign(filters, {
status: null, ownerId: null, dateFrom: null, dateTo: null,
})
sortBy.value = 'createdAt'
sortDirection.value = 'desc'
}
function applyFilter(key: keyof typeof filters, value: string | number | null) {
filters[key] = value as any
}
return {
filters: readonly(filters),
sortBy: readonly(sortBy),
sortDirection: readonly(sortDirection),
applyFilter,
resetFilters,
}
}
Both the filter panel component and the table component import useProjectFilters. They share the same reactive state. When the filter panel changes status, the table component sees the change immediately — because they’re sharing the module-level filters object.
When the user navigates away from the project listing page, the filters persist in the module-level state — because module-level state in Vue 3 persists for the lifetime of the JavaScript module (the page). If you want filters to reset on navigation, add a reset call in the page component’s onUnmounted:
// In the page component
const { resetFilters } = useProjectFilters()
onUnmounted(() => resetFilters())
If you want filters to persist across navigation (user navigates away and back), the singleton composable already does this without any additional work.
Category 3: Global Application State — Pinia
Pinia is correct for state that needs to be accessible from any component, survives navigation, and represents a meaningful concept in the application’s domain.
The characteristics of correct Pinia state:
// ✅ Correct Pinia stores — each represents a distinct application concern
useAuthStore() // the authenticated user — global, persists forever
useCartStore() // shopping cart — global, persists across routes
useNotificationStore() // notification list — global, shown from layout
useTenantStore() // current tenant — global, affects all queries
// app/stores/authStore.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('auth_token'))
const loading = ref(false)
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.roles.includes('admin') ?? false)
async function login(email: string, password: string): Promise<void> {
loading.value = true
try {
const response = await AuthApi.login({ email, password })
token.value = response.token
user.value = response.user
localStorage.setItem('auth_token', response.token)
} finally {
loading.value = false
}
}
function logout(): void {
user.value = null
token.value = null
localStorage.removeItem('auth_token')
}
async function fetchCurrentUser(): Promise<void> {
if (!token.value) return
user.value = await AuthApi.me()
}
return {
user: readonly(user),
token: readonly(token),
loading: readonly(loading),
isAuthenticated,
isAdmin,
login,
logout,
fetchCurrentUser,
}
})
The anti-pattern: Pinia as a data fetching layer
// ❌ Pinia used to fetch and cache server data — this is what TanStack Query is for
export const useProjectStore = defineStore('projects', {
state: () => ({
projects: [] as Project[],
loading: false,
error: null as string | null,
}),
actions: {
async fetchProjects() {
this.loading = true
this.error = null
try {
this.projects = await ProjectApi.list()
} catch (err) {
this.error = (err as Error).message
} finally {
this.loading = false
}
},
async createProject(data: CreateProjectDto) {
const project = await ProjectApi.create(data)
this.projects.push(project)
},
},
})
This pattern is extremely common and produces two specific problems:
Problem 1: No cache invalidation. When createProject is called, the local this.projects array is updated optimistically. But the server might return additional computed fields (slug, created_at, permissions) that differ from the local update. The store now has stale data that doesn’t match the server.
Problem 2: No staleness handling. The projects array is fetched once and never refreshed unless the component explicitly calls fetchProjects() again. A collaborator who creates a project on a different browser tab or device doesn’t appear in the other user’s list.
Both of these are server state problems, and Pinia has no built-in mechanism to solve them. TanStack Query does.
Category 4: Server State — TanStack Query
Server state is the most frequently mishandled category. The defining characteristic: the source of truth is on a server. The local state is a cache of the server’s data. Treating server state as application state — storing it in Pinia, manually managing its freshness, manually invalidating it on mutations — produces code that reimplements a fraction of what TanStack Query provides, with bugs.
npm install @tanstack/vue-query
// main.ts
import { VueQueryPlugin } from '@tanstack/vue-query'
app.use(VueQueryPlugin)
The correct pattern for fetching a list:
// composables/useProjects.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { ProjectApi } from '@/api/projectApi'
import type { Project, CreateProjectDto } from '@/features/projects'
export function useProjects(filters?: Ref<ProjectFilters>) {
return useQuery({
queryKey: ['projects', filters], // reactive: refetches when filters change
queryFn: () => ProjectApi.list(filters?.value),
staleTime: 30_000, // data is fresh for 30 seconds — no refetch if recent
gcTime: 5 * 60 * 1000, // keep in cache for 5 minutes after last use
})
}
export function useProject(id: Ref<number>) {
return useQuery({
queryKey: ['projects', id],
queryFn: () => ProjectApi.find(id.value),
enabled: computed(() => !!id.value), // don't fetch if id is null/0
})
}
export function useCreateProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateProjectDto) => ProjectApi.create(data),
onSuccess: (newProject) => {
// Invalidate the projects list — TanStack refetches automatically
queryClient.invalidateQueries({ queryKey: ['projects'] })
// Optionally seed the cache with the new project immediately
queryClient.setQueryData(['projects', newProject.id], newProject)
},
})
}
In a component:
<script setup lang="ts">
import { useProjects, useCreateProject } from '@/composables/useProjects'
import { useProjectFilters } from '@/composables/useProjectFilters'
const { filters } = useProjectFilters()
const { data: projects, isLoading, error } = useProjects(computed(() => filters))
const { mutate: createProject, isPending } = useCreateProject()
</script>
<template>
<div>
<Spinner v-if="isLoading" />
<ErrorBanner v-else-if="error" :message="error.message" />
<ProjectList v-else :projects="projects ?? []" />
<CreateProjectButton :loading="isPending" @click="createProject(newProjectData)" />
</div>
</template>
What TanStack Query handles automatically that a Pinia store doesn’t:
Automatic:
→ Cache shared between all components that use the same query key
→ Background refetch when the window regains focus
→ Deduplication: two components requesting the same data → one API call
→ Stale-while-revalidate: return cached data immediately, refetch in background
→ Error retry with exponential backoff
→ Dependent queries: don't fetch project details until user ID is known
→ Optimistic updates with automatic rollback on error
→ Cache invalidation on mutation success
Not automatic (you still handle this):
→ Which data to cache and for how long (staleTime, gcTime)
→ When to invalidate (in onSuccess callbacks)
→ Optimistic update structure (in onMutate callbacks)
When to use a simple composable instead of TanStack Query for server state:
TanStack Query has a learning curve and adds a dependency. For simple cases — one API call, no caching needed, no background refresh needed — a reactive composable is fine:
// Simple fetch composable — for one-off fetches without caching requirements
export function useFetchUser(id: Ref<number>) {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
watch(id, async (newId) => {
loading.value = true
error.value = null
try {
user.value = await UserApi.find(newId)
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}, { immediate: true })
return { user: readonly(user), loading: readonly(loading), error: readonly(error) }
}
Use TanStack Query when: the same data is needed in multiple components, you need cache sharing between routes, background refresh matters, or mutations need to invalidate cached data. Use a simple composable when: the data is fetched once, shown in one component, and doesn’t need to be shared or invalidated.
The Three Anti-Patterns
Anti-pattern 1: Pinia for everything (Pinia sprawl)
Signs: a stores/ directory with 15+ files, stores named useUiStore, useModalStore, useFilterStore. Every piece of state in the application runs through Pinia regardless of scope.
Result: stores that are impossible to understand because they contain both “the current user” (global, meaningful) and “is the date picker open” (local, meaningless globally). The actions that modify these states are called from everywhere, making it impossible to trace data flow.
Diagnosis: ask “does this state need to survive navigation?” and “does more than one route need this state?” If both are no, it shouldn’t be in Pinia.
Anti-pattern 2: Composables for global state (state reset on navigation)
Signs: a user adds an item to a cart implemented as a per-instance composable, navigates to checkout, and the cart is empty. Or: filter state stored in a per-instance composable resets every time a modal re-renders.
The specific bug:
// ❌ Per-instance composable used as if it were a singleton
// components/CartButton.vue — calls useCart()
// components/CartSidebar.vue — calls useCart()
// → Each gets its own cart ref. Adding to cart in CartButton doesn't update CartSidebar.
export function useCart() {
const items = ref<CartItem[]>([]) // ← per-instance, not shared
// ...
}
// ✅ Singleton: move the ref outside the function
const items = ref<CartItem[]>([]) // ← module-level, shared
export function useCart() {
// ...
}
Or just use Pinia, which is a better fit for cart state (global, persists across navigation, needs DevTools integration).
Anti-pattern 3: Pinia as a data fetching layer (reinventing TanStack Query poorly)
Signs: Pinia actions that call APIs, loading and error state in every store, components that call store.fetchX() in onMounted, data that goes stale without anyone noticing, mutations that update local state but diverge from server state over time.
The tell:
// ❌ Pinia recreating server state management without the features
export const useProjectStore = defineStore('projects', {
state: () => ({
projects: [],
loading: false,
lastFetched: null as Date | null,
}),
actions: {
async fetchProjects() {
// Manual stale check — reinventing TanStack Query's staleTime
if (this.lastFetched && Date.now() - this.lastFetched.getTime() < 30000) return
this.loading = true
this.projects = await ProjectApi.list()
this.lastFetched = new Date()
this.loading = false
},
},
})
This is the beginning of a bad reimplementation of a cache. It will grow to include retry logic, error state, cache invalidation on mutation, and eventually something resembling TanStack Query — implemented inconsistently and without the years of edge case handling that TanStack provides.
The Decision at a Glance
State I'm deciding about
│
├── Is it derived from other state?
│ → computed()
│
├── Does it come from a server (API)?
│ → Simple: reactive fetch composable
│ Complex (caching, sharing, invalidation): TanStack Query
│
├── Is it used only in this component?
│ → ref() / reactive() in <script setup>
│
├── Is it shared but resets when I leave this route/feature?
│ → Singleton composable (module-level ref)
│
└── Is it global and persists across navigation?
→ Pinia store
The Conversation That Ends the Debate
When the argument “should we use Pinia for this?” comes up in code review or architecture discussion, the framework gives you three questions to ask instead:
- Does this state come from a server? → TanStack Query
- Does it need to survive navigation? → Pinia. Otherwise → composable or local
- Is it only used in one component? → local ref(). Otherwise → shared composable
Three questions, four possible answers, no argument. The tool follows from the state’s nature, not from team preference or familiarity with one particular approach.
