6 min read
On this page

State Patterns

Knowing how Pinia works is step one. The harder question is when to use it. Every piece of state in your application lives somewhere: a local ref, a composable, a Pinia store, a URL query parameter, or the server. Picking the wrong location creates either unnecessary complexity (a Pinia store for a toggle that one component uses) or painful bugs (local state that should have been global).

The State Location Spectrum

Think of state locations from most local to most global:

  1. Component-local refs: const isOpen = ref(false). Dies when the component unmounts.
  2. Composable state: A useForm() composable that manages validation state. Scoped to the component that calls it, but the logic is reusable.
  3. Provide/inject: Shared across a subtree. A form context shared between a <Form> and its <FormField> children.
  4. Pinia stores: Global singletons. Auth state, shopping cart, notification queue.
  5. URL state: Route params and query strings. Current page, active tab, search filters.
  6. Server state: Data fetched from APIs. User profiles, product listings, dashboard metrics.

The right default is the most local option that works. Start with a local ref. Move it up only when a second consumer appears.

When to Use Local Refs

Most state is local. A modal's open/closed state, a form input's current value, whether a dropdown is expanded -- these belong in component-local refs:

<script setup lang="ts">
import { ref } from 'vue'

const isExpanded = ref(false)
const selectedTab = ref<'details' | 'reviews' | 'specs'>('details')
</script>

There is no benefit to putting isExpanded in a Pinia store. No other component cares about it. It does not persist across navigations. It does not need to survive a page reload.

When to Use Composables

Use composables when the logic is reusable but the state is still component-scoped. Each component that calls the composable gets its own independent state:

// composables/useForm.ts
import { ref, computed, type Ref } from 'vue'

export function useForm<T extends Record<string, unknown>>(initialValues: T) {
  const values = ref({ ...initialValues }) as Ref<T>
  const errors = ref<Partial<Record<keyof T, string>>>({})
  const dirty = ref(false)

  const isValid = computed(() =>
    Object.keys(errors.value).length === 0
  )

  function setField<K extends keyof T>(field: K, value: T[K]) {
    values.value[field] = value
    dirty.value = true
  }

  function setError<K extends keyof T>(field: K, message: string) {
    errors.value[field] = message
  }

  function reset() {
    values.value = { ...initialValues }
    errors.value = {}
    dirty.value = false
  }

  return { values, errors, dirty, isValid, setField, setError, reset }
}

Two forms on the same page each get their own values, errors, and dirty state. That is the point -- composable state is not shared unless you explicitly design it to be.

Singleton Composables

Sometimes you want a composable with shared state. Put the state outside the function:

// composables/useOnlineStatus.ts
import { ref } from 'vue'

const isOnline = ref(navigator.onLine)
const initialized = ref(false)

export function useOnlineStatus() {
  if (!initialized.value) {
    window.addEventListener('online', () => { isOnline.value = true })
    window.addEventListener('offline', () => { isOnline.value = false })
    initialized.value = true
  }

  return { isOnline }
}

Every component calling useOnlineStatus() gets the same isOnline ref. This is effectively a global store without Pinia. It works for simple cases, but once you need devtools visibility, persistence, or SSR hydration, Pinia is the better choice.

There is a catch with this pattern: module-level state is shared across requests in SSR. Two users hitting the server simultaneously would share the same ref. In Nuxt, use useState or Pinia instead of module-level refs for anything request-scoped.

When to Use Pinia

Pinia is for state that is:

  • Shared across multiple components that are not in a parent-child relationship
  • Persistent across route navigations (the cart should not empty when you navigate to the product page)
  • Global in nature: authentication, user preferences, shopping cart, notification queue
// This belongs in Pinia
export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)
  const isLoggedIn = computed(() => user.value !== null)

  // Used by the nav bar, the profile page, the checkout flow,
  // API interceptors, and route guards
  return { user, token, isLoggedIn }
})

A rule of thumb: if you are creating a Pinia store for state that only one component reads, you are overcomplicating things.

Lifting State

When two sibling components need the same data, the standard approach is lifting state to their shared parent:

<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import Sidebar from './Sidebar.vue'
import MainContent from './MainContent.vue'

const selectedCategory = ref<string | null>(null)
</script>

<template>
  <div class="layout">
    <Sidebar :selected="selectedCategory" @select="selectedCategory = $event" />
    <MainContent :category="selectedCategory" />
  </div>
</template>

This works for one or two levels. Once you are passing props through three or more intermediate components that do not use them, consider provide/inject for tree-scoped sharing or Pinia for global sharing.

URL as State

Some state belongs in the URL. Search filters, pagination, sort order, active tabs -- anything the user might want to bookmark or share:

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

const page = computed({
  get: () => Number(route.query.page) || 1,
  set: (value: number) => {
    router.push({ query: { ...route.query, page: String(value) } })
  },
})

const search = computed({
  get: () => (route.query.q as string) || '',
  set: (value: string) => {
    router.push({ query: { ...route.query, q: value || undefined, page: '1' } })
  },
})
</script>

<template>
  <input v-model="search" placeholder="Search..." />
  <ProductList :search="search" :page="page" />
  <Pagination v-model="page" />
</template>

URL state survives page reloads, works with the browser's back button, and is shareable. Putting a search query in Pinia instead of the URL is a mistake you only make once -- the user refreshes the page and their search disappears.

Server State with Nuxt

In Nuxt applications, server-fetched data has its own management layer with useFetch and useAsyncData. These are not substitutes for Pinia -- they solve a different problem:

<script setup lang="ts">
// Server state: fetched, cached, and hydrated automatically
const { data: products, refresh } = await useFetch('/api/products')

// Client state in Pinia: the cart, managed entirely on the client
const cart = useCartStore()
</script>

Do not put fetched data into Pinia stores. Nuxt's data fetching handles caching, de-duplication, and SSR hydration. Copying that data into Pinia doubles your state surface area and creates synchronization bugs.

The exception is when fetched data needs to be mutated on the client before being sent back. An editor that loads a document, lets the user modify it, and saves it back -- that intermediate editing state might live in a Pinia store.

Optimistic Updates

Update the UI immediately, then roll back if the server call fails. This is what makes apps feel fast:

export const useTaskStore = defineStore('tasks', () => {
  const tasks = ref<Task[]>([])

  async function toggleComplete(taskId: string) {
    const task = tasks.value.find(t => t.id === taskId)
    if (!task) return

    // Optimistic update
    const previousState = task.completed
    task.completed = !task.completed

    try {
      await fetch(`/api/tasks/${taskId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: task.completed }),
      })
    } catch {
      // Rollback on failure
      task.completed = previousState
    }
  }

  return { tasks, toggleComplete }
})

Slack, Linear, and Notion all use this pattern extensively. The user sees the change instantly. If the server rejects it, the UI reverts.

State Machines with XState

For state with complex transitions and rules, a finite state machine is clearer than a bunch of booleans and if/else chains. Consider a file upload:

// Without a state machine: boolean soup
const uploading = ref(false)
const uploaded = ref(false)
const error = ref(false)
const retrying = ref(false)
// What if uploading AND error are both true? Is that valid?

XState makes states explicit and transitions predictable:

// machines/uploadMachine.ts
import { createMachine, assign } from 'xstate'

interface UploadContext {
  file: File | null
  progress: number
  error: string | null
}

export const uploadMachine = createMachine({
  id: 'upload',
  initial: 'idle',
  context: {
    file: null,
    progress: 0,
    error: null,
  } as UploadContext,
  states: {
    idle: {
      on: {
        SELECT_FILE: {
          target: 'ready',
          actions: assign({ file: (_, event) => event.file }),
        },
      },
    },
    ready: {
      on: {
        UPLOAD: 'uploading',
        CANCEL: { target: 'idle', actions: assign({ file: null }) },
      },
    },
    uploading: {
      on: {
        PROGRESS: {
          actions: assign({ progress: (_, event) => event.percent }),
        },
        SUCCESS: 'done',
        ERROR: {
          target: 'failed',
          actions: assign({ error: (_, event) => event.message }),
        },
      },
    },
    done: { type: 'final' },
    failed: {
      on: {
        RETRY: { target: 'uploading', actions: assign({ error: null, progress: 0 }) },
        CANCEL: { target: 'idle', actions: assign({ file: null, error: null }) },
      },
    },
  },
})

Use it in Vue with @xstate/vue:

<script setup lang="ts">
import { useMachine } from '@xstate/vue'
import { uploadMachine } from '@/machines/uploadMachine'

const { state, send } = useMachine(uploadMachine)

function onFileSelected(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0]
  if (file) send({ type: 'SELECT_FILE', file })
}
</script>

<template>
  <div v-if="state.matches('idle')">
    <input type="file" @change="onFileSelected" />
  </div>

  <div v-else-if="state.matches('ready')">
    <p>{{ state.context.file?.name }}</p>
    <button @click="send({ type: 'UPLOAD' })">Upload</button>
    <button @click="send({ type: 'CANCEL' })">Cancel</button>
  </div>

  <div v-else-if="state.matches('uploading')">
    <progress :value="state.context.progress" max="100" />
  </div>

  <div v-else-if="state.matches('done')">
    <p>Upload complete.</p>
  </div>

  <div v-else-if="state.matches('failed')">
    <p>Error: {{ state.context.error }}</p>
    <button @click="send({ type: 'RETRY' })">Retry</button>
  </div>
</template>

XState is heavy for a toggle. But for workflows with multiple states, guards, and transitions -- checkout flows, multi-step forms, connection management -- it prevents entire categories of bugs that boolean-based state management creates.

Common Pitfalls

  • Putting everything in Pinia: A Pinia store for every piece of state is Vuex-brain. Most state is local. Use Pinia only for genuinely shared, persistent state.
  • Putting URL-worthy state in Pinia: Search filters, pagination, and selected tabs should be URL query params. Users expect to bookmark and share these.
  • Duplicating server state into Pinia: In Nuxt apps, useFetch handles caching and hydration. Copying its output into a store creates two sources of truth.
  • Using booleans for complex state: When you have loading, error, retrying, and success as independent booleans, you can represent impossible states (loading AND error). Use a discriminated union or a state machine instead.
  • Not lifting state soon enough: If two components need the same data and you solve it by duplicating state, you now have a synchronization problem. Lift to the nearest common ancestor or use a store.
  • Module-level state in SSR: Refs declared outside a function are shared across requests in server environments. In Nuxt, use useState() or Pinia for state that must be request-scoped.
  • Overengineering with XState for simple cases: A ref<boolean> for a modal's open state is fine. State machines shine for multi-step processes with complex rules, not for simple toggles.

Key Takeaways

  • Start with the most local state option (component ref) and move to more global options only when needed.
  • Composables encapsulate reusable logic with independent state per consumer. Singleton composables share state but lack devtools and SSR support.
  • Pinia is for globally shared, persistent state: auth, cart, preferences. Do not use it for component-local toggles.
  • URL state (route query params) is the right choice for anything the user might bookmark or share.
  • Server state in Nuxt stays in useFetch/useAsyncData. Do not copy it into Pinia.
  • State machines (XState) prevent impossible states in complex workflows. Use them for multi-step processes, not simple flags.
  • Derive state with computed instead of storing redundant copies. Storing both items and itemCount when itemCount is just items.length creates a synchronization problem.