Skip to main content

What are composables in Nuxt?

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.

Short Answer

Interview ready
Premium

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

Finished reading?