useState for SSR-Safe Shared State
Why a plain module-level ref leaks data between users under SSR, and how useState avoids it.
읽는 데 2분
In a client-only Vue app, a common pattern for sharing state between components without a full store library is a ref created once at module scope and imported wherever it's needed. Under Nuxt's server-side rendering, that pattern has a serious bug: it leaks data between different users' requests.
The problem with a plain module-level ref
// composables/useCounter.ts — DON'T do this in Nuxt
import { ref } from "vue";
const count = ref(0); // created once, when the module first loads
export function useCounter() {
return count;
}In the browser, this is fine — one user, one module instance. On the server, though, a Node.js process typically stays alive across many requests from many different users, and a module is only evaluated once per process, not once per request. That means count here is a single shared value sitting in server memory: one user's increment would show up in the HTML rendered for a completely different user's next request.
useState fixes this by keying state per request
useState is Nuxt's auto-imported composable for state that needs to survive SSR and be shared between components — it stores the value keyed to the current request on the server, and keyed globally (but still per-browser-tab) on the client:
// composables/useCounter.ts
export function useCounter() {
return useState("counter", () => 0);
}<script setup>
const count = useCounter();
</script>
<template>
<button @click="count++">Count: {{ count }}</button>
</template>The first argument, "counter", is a required key — like useAsyncData's cache key, it's what lets Nuxt look up the same shared value from multiple components without them needing to import from the same module instance. The second argument is a factory function producing the initial value, called only the first time that key is accessed for a given request or session.
useState also hydrates like useFetch does
Because useState participates in the same SSR-to-client handoff as useFetch, a value set on the server is serialized into the page and picked up by the client without being recomputed — set a value during SSR (say, from a cookie or the request), and the client starts with that same value instead of resetting to the factory's default.
When to reach for something bigger
useState is meant for simple, page- or app-level shared state — a cart item count, a "is the mobile nav open" flag, a piece of user info fetched once. For state with complex mutation logic, persistence, or devtools needs, a dedicated store library like Pinia (which Nuxt has official support for) is usually the better fit. The rule of thumb: if it's shared and simple, useState; if it's shared and has real business logic around it, a store.