Skip to main content

How to manage state in Nuxt 3?

Nuxt 3 state management gives you two main tools: useState for simple shared values with zero setup, and Pinia for stores with actions, getters, and DevTools support.

Theory

TL;DR

  • useState is like a shared fridge in an office: everyone reads the same value, SSR hydration works automatically
  • Pinia is a full kitchen with multiple chefs, recipes, and a window into what is cooking (DevTools)
  • Main difference: useState handles SSR serialization for you; Pinia adds actions, computed getters, plugins, and scales to 50+ stores
  • Under 5 primitive values with no logic? Use useState. Auth, carts, API calls? Use Pinia.

Quick example

vue
// composables/useTheme.ts export const useTheme = () => { const theme = useState('theme', () => 'light'); // SSR-safe, globally shared const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; }; return { theme, toggle }; }; // pages/index.vue const { theme, toggle } = useTheme(); // theme.value === 'light' on server and client - no mismatch

The string key 'theme' is what makes the value shared. Two components calling useTheme() get the exact same ref, not two separate ones. That one detail trips up most devs the first time.

Key difference

useState registers a reactive ref inside Nitro's runtime context during SSR. The value gets serialized into the HTML payload automatically, then hydrated on the client without any config. Pinia builds stores using Vue's effectScope per store, giving you isolated reactivity, async actions, and computed getters. But Pinia needs @pinia/nuxt in your modules and requires thinking about store initialization on the server.

When to use

  • Theme, locale, a simple UI flag: useState. One line, done.
  • Local component data that no other component needs: ref or reactive.
  • User auth, shopping cart, forms with API calls: Pinia with actions.
  • Derived lists, calculated totals: Pinia computed getters.
  • Middleware or plugin state: useState or Nuxt runtime config.

Comparison table

FeatureuseStatePinia
SetupZero boilerplatedefineStore + add @pinia/nuxt
SSR safetyAutomatic serialization and hydrationSupported via useNuxtApp().$pinia init
ReactivityVue ref internallyFull Vue (ref, computed, actions)
DevToolsNonePinia DevTools with time travel
Scale1-5 primitive valuesUnlimited stores, plugins, persistence
Best forCounters, themes, locale in small appsAuth, CRUD, e-commerce production apps

How it works internally

During SSR, useState registers a ref in Nitro's runtime context. When the page renders, Nitro serializes the ref's value into the HTML payload. The client reads it and hydrates without re-fetching. Server and client always match.

Pinia works differently. Each store runs inside Vue's effectScope, which gives isolated reactivity per store. Nuxt auto-imports $pinia from its runtime config. The store state gets serialized into useNuxtApp().payload on the server and restored on the client side.

Common mistakes

1. Mutating useState only inside onMounted

Server renders the initial value. Client changes it after mount. Vue throws a hydration mismatch error.

js
// Wrong: server renders 0, client jumps to 5 const count = useState('count', () => 0); onMounted(() => { count.value = 5; }); // Fix: fetch the value during SSR const { data: count } = useAsyncData('count', () => $fetch('/api/count'));

Reddit's r/Nuxt gets this exact complaint weekly: "counter was 0 on server, 5 on client." The fix is always useAsyncData.

2. Using ref instead of useState for shared values

A plain ref lives inside one composable instance. Each component calling the composable gets its own copy. It also resets on page navigation.

js
// Wrong: not shared, resets on navigate const count = ref(0); // Fix: shared across components and navigations const count = useState('count', () => 0);

3. Skipping Pinia SSR initialization

The @pinia/nuxt module handles the wiring. Without it in nuxt.config.ts, stores come up empty after hydration.

js
// nuxt.config.ts export default defineNuxtConfig({ modules: ['@pinia/nuxt'] });

4. Deep objects in useState

Only top-level values serialize cleanly. Nested objects can cause partial serialization bugs. Flatten your data structure or move it to Pinia and use toRaw() where needed.

Real-world usage

  • Shopsys Nuxt e-commerce module: Pinia for carts and orders, useState for theme and locale
  • nuxt.com docs (Nuxt Content v2): useState for search filters
  • Sidebase Nuxt Auth: Pinia stores for sessions, useState for UI flags
  • Strapi + Nuxt setups: Pinia for fetched content entities

Follow-up questions

Q: What causes an SSR hydration mismatch with useState?
A: The server serializes the initial useState value into the HTML payload. If the client mutates it before hydration completes (for example in onMounted), Vue throws a mismatch error. Fix it with useAsyncData or read from useNuxtApp().payload on the server.

Q: When does Pinia outperform useState?
A: When you have more than 3 related values, need async actions that coordinate API calls, or want DevTools for debugging. Pinia scales to 50+ stores in production without issues.

Q: How do you persist Pinia state across page refreshes?
A: Add @pinia-plugin-persistedstate/nuxt to your modules and set persist: true in the store definition. It serializes to cookies or localStorage and is SSR-safe.

Q: What is the difference between useState and useNuxtApp().payload?
A: Payload is static per-request and only exists during SSR. useState is reactive and can be changed on the client after hydration.

Q: In a Nuxt app with island components, how do you scope state to avoid global leaks?
A: Prefix useState keys with a unique identifier like 'cart-' + userId. For Pinia, use composables with shallowRef inside island components and avoid global stores inside <ClientOnly> blocks.

Examples

Basic: shared counter with useState

ts
// composables/useCounter.ts export const useCounter = () => { const count = useState('counter', () => 0); const increment = () => count.value++; const decrement = () => count.value--; return { count, increment, decrement }; }; // ComponentA.vue and ComponentB.vue - same call, same ref const { count, increment } = useCounter(); // Increment in ComponentA instantly updates count in ComponentB

Both components share one ref because useState uses the same 'counter' key. Switch to a plain ref(0) and they immediately become independent copies.

Intermediate: e-commerce cart with Pinia

ts
// stores/cart.ts export const useCartStore = defineStore('cart', () => { const items = ref([]); const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0) ); async function addItem(product) { items.value.push(product); await $fetch('/api/cart', { method: 'POST', body: { productId: product.id } }); } return { items, total, addItem }; }); // components/Cart.vue const cart = useCartStore(); await cart.addItem({ id: 1, name: 'Shirt', price: 20 }); // cart.items = [{ id: 1, name: 'Shirt', price: 20 }] // cart.total = 20

total stays in sync with items automatically via the computed getter. For SSR, the cart starts empty on the server and hydrates on the client when user data loads.

Advanced: async state and SSR mismatch

ts
// composables/useAsyncUser.ts // Wrong - async init in onMounted is client-only export const useAsyncUserWrong = () => { const user = useState('user', () => null); onMounted(async () => { user.value = await $fetch('/api/user'); // Server sends null, client updates - mismatch }); return { user }; }; // Correct - server-safe async via useAsyncData // pages/profile.vue const { data: user } = await useAsyncData('user', () => $fetch('/api/user')); // Fetches on server, serializes to payload, hydrates on client - no mismatch

The wrong version puts async logic in onMounted, which is client-only. The server sends null, the client updates to the real user, and Vue logs a mismatch warning. useAsyncData runs on the server, puts the result in the HTML payload, and the client reads it directly.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?