Suggest an editImprove this articleRefine the answer for “How to manage state in Nuxt 3?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Nuxt 3 state management** uses `useState` for simple shared values that sync across SSR and client automatically, and Pinia for stores with actions and DevTools. ```ts const theme = useState('theme', () => 'light'); // SSR-safe, shared globally ``` **Key:** `useState` for 1-5 primitives, Pinia for auth, carts, and API logic.Shown above the full answer for quick recall.Answer (EN)Image**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 | Feature | useState | Pinia | |---|---|---| | **Setup** | Zero boilerplate | `defineStore` + add `@pinia/nuxt` | | **SSR safety** | Automatic serialization and hydration | Supported via `useNuxtApp().$pinia` init | | **Reactivity** | Vue ref internally | Full Vue (ref, computed, actions) | | **DevTools** | None | Pinia DevTools with time travel | | **Scale** | 1-5 primitive values | Unlimited stores, plugins, persistence | | **Best for** | Counters, themes, locale in small apps | Auth, 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.