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
useStateis 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:
useStatehandles 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
// 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 mismatchThe 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:
reforreactive. - User auth, shopping cart, forms with API calls: Pinia with actions.
- Derived lists, calculated totals: Pinia computed getters.
- Middleware or plugin state:
useStateor 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.
// 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.
// 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.
// 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,
useStatefor theme and locale - nuxt.com docs (Nuxt Content v2):
useStatefor search filters - Sidebase Nuxt Auth: Pinia stores for sessions,
useStatefor 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
// 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 ComponentBBoth 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
// 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 = 20total 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
// 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 mismatchThe 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 readyA concise answer to help you respond confidently on this topic during an interview.