Explore
State Management in Nuxt: From Simple to Complex

State Management in Nuxt: From Simple to Complex

Pinia is Nuxt's recommended state solution, but not every piece of state needs a store. Here's a practical framework for deciding where state belongs.

State Management in Nuxt: From Simple to Complex

State management is one of the most common sources of complexity in Vue and Nuxt applications. The complexity isn't inevitable — it usually comes from treating all state the same way, regardless of whether it needs to be shared.

Here's a framework for thinking about state, and how it applies practically in Nuxt.


There are three kinds of state

Local state: Lives in a single component. No other component needs it.

  • Whether a dropdown is open
  • The current value of a form input
  • A toggle button's active state

Shared state: Needs to be accessible by multiple components, but is still temporary.

  • The current user session
  • A notification queue
  • A filter or search query used across several components

Server state: Data that originates from an API and needs to be cached, revalidated, and kept in sync with the server.

  • The list of projects
  • The current user's profile
  • Search results

Each type belongs in a different place.


Local state: use ref and reactive

Don't reach for a store for local state. Component-local state is the right tool:

<script setup>
const isOpen = ref(false)
const formValues = reactive({
  email: '',
  password: ''
})
</script>

Simple, co-located with the component that owns it, no indirection.


Shared state: useState or Pinia

For state that needs to be shared between components in a subtree, Vue's provide/inject pattern works well. For truly global state, Nuxt's useState composable or a Pinia store are the right tools.

useState for simple global values:

// composables/useUser.ts
export const useCurrentUser = () => useState<User | null>('currentUser', () => null)

useState is SSR-safe (it syncs between server and client) and lightweight. Good for things like current user, locale, theme preference.

Pinia for complex, action-rich stores:

// stores/notifications.ts
export const useNotificationStore = defineStore('notifications', () => {
  const notifications = ref<Notification[]>([])

  function add(notification: Omit<Notification, 'id'>) {
    const id = randomId()
    notifications.value.push({ ...notification, id })
    // Auto-remove after 5 seconds
    setTimeout(() => remove(id), 5000)
  }

  function remove(id: string) {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }

  return { notifications: readonly(notifications), add, remove }
})

Pinia stores have built-in DevTools support, action logging, and TypeScript inference. Use them for state that has associated actions and business logic.


Server state: useFetch and useAsyncData

Server state is different from local state. It comes from an API. It can go stale. Multiple components might need the same data. It needs loading and error states.

Nuxt's built-in composables handle this well:

<script setup>
// Fetch data, with SSR support
const { data: projects, pending, error, refresh } = await useFetch('/api/projects')
</script>

useFetch deduplicates requests (if two components fetch the same URL, only one network request fires), handles SSR hydration, and supports automatic revalidation.

For more complex caching, TanStack Query (Vue Query) provides the same patterns as React Query:

import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'

const queryClient = useQueryClient()

const { data: projects } = useQuery({
  queryKey: ['projects'],
  queryFn: () => $fetch('/api/projects')
})

const { mutate: createProject } = useMutation({
  mutationFn: (data) => $fetch('/api/projects', { method: 'POST', body: data }),
  onSuccess: () => {
    // Invalidate and refetch projects
    queryClient.invalidateQueries({ queryKey: ['projects'] })
  }
})

Vue Query is worth adding when you have complex caching requirements, optimistic updates, or background revalidation needs.


The decision flowchart

When you're deciding where to put a piece of state:

  1. Is it used by only one component?ref/reactive in that component
  2. Is it used by a small subtree of related components?provide/inject
  3. Is it global and simple?useState
  4. Is it global with associated actions/logic? → Pinia store
  5. Does it come from an API?useFetch/useAsyncData or Vue Query
  6. Does it live in the URL?useRoute/useRouter with query params

Common mistakes

Putting everything in Pinia. A Pinia store for a toggle that only exists in one component is unnecessary indirection. Use local state.

Fetching the same data in multiple components. If three components all fetch /api/projects independently, you have three network requests and three potential sources of inconsistency. Fetch once at a parent level, or use useFetch (which deduplicates) or Vue Query (which caches).

Storing derived state in the store. If a value can be computed from other state, make it a computed rather than storing it separately. Storing derived state creates a synchronisation problem.

Mutating store state directly. Define actions for mutations. Direct mutation bypasses DevTools logging and makes debugging harder.


Keeping stores focused

Each store should have a clear, single domain. A store that manages both user authentication, notification state, and modal visibility is doing too much.

Prefer many small, focused stores over one large store. They're easier to test, easier to reason about, and easier to find when debugging.

Building a complex Vue/Nuxt application and want clean architecture? Let's talk →