Suggest an editImprove this articleRefine the answer for “What are composables in Nuxt?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Composables in Nuxt** are auto-imported Vue Composition API functions from the `composables/` directory that return reactive state and logic shared across components. ```ts const { count, increment } = useCounter() // no import statement needed ``` **Key:** use `useState` inside composables for SSR-safe state that persists across page navigation.Shown above the full answer for quick recall.Answer (EN)Image**Composables in Nuxt** are auto-imported Vue Composition API functions that live in the `composables/` directory and encapsulate reactive state together with the logic that operates on it. ## Theory ### TL;DR - Think of a composable like a shared toolkit on a shelf: call `useCounter()` anywhere in the app and it brings its own state and methods, no import statement needed. - Main difference from plain Vue composables: Nuxt auto-imports them at build time, and `useState` gives SSR-safe shared state that survives page navigation. - Use composables when logic repeats across 3+ components. For a single component, inline `ref`/`computed` is enough. - Nuxt scans `composables/` at build and adds every named export to `.nuxt/imports.d.ts`. ### Quick example ```typescript // composables/useCounter.ts export const useCounter = () => { const count = useState('counter', () => 0) // SSR-safe, keyed shared state const increment = () => count.value++ const decrement = () => count.value-- return { count, increment, decrement } } ``` ```vue <!-- pages/index.vue — no import statement anywhere --> <script setup> const { count, increment } = useCounter() </script> <template> <div>Count: {{ count }} <button @click="increment()">+</button></div> </template> ``` `useState('counter', ...)` creates a reactive ref keyed to the string `'counter'`. On the server, that key maps to a per-request store so state never leaks between users. On the client, the value persists across navigation. Every component that calls `useCounter()` works with the same reactive ref. ### Key difference from plain Vue composables Plain Vue composables are functions you write and import manually. Nuxt adds two things on top: automatic imports (the Vite plugin injects them into every component at build time) and `useState`, which bridges the SSR/client gap. A regular `ref` inside a composable is local to the component that called it. `useState` with a unique string key is shared across all components and does not reset on client-side navigation. ### When to use - **Repeated fetch logic** across multiple pages: a composable instead of copy-pasting `$fetch` calls. - **Shared app state** like auth session or color theme: a `useState` composable before reaching for Pinia. - **Component-specific logic**: keep it inline. A 10-line composable for a single `computed` adds files without adding value. - **Plugins and middleware** that need reactive state: composables work there too. Pinia makes sense for normalized global state written from many unrelated parts of the app. For self-contained logic, a composable is simpler and sufficient. ### How Nuxt's auto-import works Nuxt's Vite plugin scans `composables/` at build time and generates an auto-imports map in `.nuxt/imports.d.ts`. Every file in that directory and every named export inside it becomes globally available across components and pages. TypeScript picks up the types automatically, editor autocomplete works, and there is not a single import statement in component files. During SSR, `useState` keys into a Map scoped to the current request. On the client, Vue's reactivity proxies make sure any write to a ref triggers re-renders in every component that reads it. No prop drilling required, no event bus. ### Common mistakes **1. Forgetting `.value` on `useState`** ```typescript // Wrong const count = useState('count', () => 0) setTimeout(() => count = 5, 1000) // ❌ replaces the ref object, not its value // Correct setTimeout(() => count.value = 5, 1000) // ✅ ``` `useState` returns a ref. Direct assignment replaces the ref object itself, so reactivity breaks and nothing re-renders. **2. Treating composables like singletons** ```typescript // Wrong export const useConfig = () => { const config = useState('config', () => fetchConfig()) // ❌ factory runs once per key, but async issues arise return { config } } ``` The factory in `useState` runs only once per key, but putting async work inside it directly causes unpredictable behavior because the factory is synchronous. Use `useLazyAsyncData` or fetch inside `onMounted` instead. **3. Accessing browser APIs at the top level** ```typescript // Wrong export const useStorage = () => { const data = ref(localStorage.getItem('key')) // ❌ localStorage does not exist on the server return { data } } // Correct export const useStorage = () => { const data = ref(null) if (process.client) { data.value = localStorage.getItem('key') } return { data } } ``` SSR renders the component on the server first. `localStorage` is undefined there. Server renders `null`, client renders a value, you get a hydration mismatch error. Guard with `process.client` or align initial values with `useState`. **4. Skipping event listener cleanup** ```typescript // Wrong export const useWindowSize = () => { const width = ref(window.innerWidth) window.addEventListener('resize', () => width.value = window.innerWidth) // ❌ new listener added on every navigation return { width } } // Correct export const useWindowSize = () => { const width = ref(process.client ? window.innerWidth : 0) if (process.client) { const update = () => width.value = window.innerWidth window.addEventListener('resize', update) onBeforeUnmount(() => window.removeEventListener('resize', update)) // ✅ removed when component unmounts } return { width } } ``` Without cleanup, five page visits stack five listeners firing on every resize. This one shows up in production before it shows up in code review. ### Real-world usage - **Nuxt Auth module**: `useUser()` shares the session object across layouts, pages, and middleware. - **Nuxt UI**: `useTabs()` manages tab state across nested components without lifting state. - **Nuxt Content v2**: `useAsyncData()` fetches markdown content with cache keys. - **Supabase + Nuxt**: `useSupabaseUser()` returns reactive auth state that updates on login and logout. ### Follow-up questions **Q:** How does `useState` differ from `ref` in a composable? **A:** `ref` is local to the component that called the composable. `useState` with a string key is shared across all components and persists across client-side navigation. On the server each request gets its own isolated key-value store, so state never leaks between users. **Q:** What happens if two composables write to the same `useState` key? **A:** Last write wins. Both composables hold a reference to the same reactive ref, so any write from either one propagates immediately to every component reading it. **Q:** How does the auto-import mechanism work exactly? **A:** Nuxt scans `composables/` at build time and generates `.nuxt/imports.d.ts`. The Vite plugin injects the imports into every component file automatically. You never write the import yourself, but it is there after the build step. **Q:** How do you make a composable run only on the client? **A:** Wrap browser-specific code in `if (process.client) { ... }`. Alternatively, call the composable inside `onMounted()` so it runs after hydration. For async data, `useLazyAsyncData` defers execution to the client side. **Q (senior level):** You have a hydration mismatch error coming from a composable. How do you debug it? **A:** First check whether the composable accesses client-only APIs like `localStorage`, `window`, or `document` at the top level outside any guard. Then verify that `useState` keys are consistent between server and client renders. Look at the Nitro storage layer if the composable uses server-side persistence. Adding `process.client` guards or replacing `ref` with `useState` usually aligns the initial values on both sides. ## Examples ### Basic: shared counter across pages ```typescript // composables/useCounter.ts export const useCounter = () => { const count = useState('counter', () => 0) const increment = () => count.value++ const decrement = () => count.value-- return { count, increment, decrement } } ``` ```vue <!-- pages/home.vue and pages/about.vue both call useCounter() --> <script setup> const { count, increment } = useCounter() </script> <template> <button @click="increment">Count: {{ count }}</button> </template> ``` Navigate from `/home` to `/about`. The counter keeps its value because `useState('counter', ...)` uses the same key on both pages. Both components share one reactive ref without any store setup. ### Intermediate: user profile fetch in a dashboard ```typescript // composables/useUserProfile.ts export const useUserProfile = () => { const profile = ref(null) const loading = ref(false) const error = ref('') const fetchProfile = async (userId: string) => { loading.value = true error.value = '' try { const data = await $fetch(`/api/users/${userId}`) profile.value = data } catch (e) { error.value = e.message } finally { loading.value = false } } return { profile, loading, error, fetchProfile } } ``` ```vue <!-- components/Dashboard.vue --> <script setup> const route = useRoute() const { profile, loading, error, fetchProfile } = useUserProfile() onMounted(() => fetchProfile(route.params.id as string)) </script> <template> <div v-if="loading">Loading...</div> <div v-else-if="error">{{ error }}</div> <UserCard v-else :user="profile" /> </template> ``` Any page that needs a user profile calls `useUserProfile()` and gets the same fetch/loading/error pattern. `Dashboard.vue`, `ProfilePage.vue`, and `AdminView.vue` all reuse the same composable instead of duplicating the fetch logic. ### Advanced: window resize listener with proper cleanup ```typescript // composables/useWindowSize.ts export const useWindowSize = () => { const width = ref(process.client ? window.innerWidth : 0) if (process.client) { const update = () => { width.value = window.innerWidth } window.addEventListener('resize', update) onBeforeUnmount(() => window.removeEventListener('resize', update)) } return { width } } ``` ```vue <script setup> const { width } = useWindowSize() </script> <template> <div>Viewport: {{ width }}px</div> </template> ``` The `process.client` guard prevents `window` access during SSR. `onBeforeUnmount` removes the listener when the component unmounts, so navigating between pages never stacks up extra listeners. The version without cleanup is the classic interview trap question about memory leaks in Nuxt composables.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.