Skip to main content

Що таке composables в Nuxt?

Composables в Nuxt - це функції Vue Composition API, що автоматично імпортуються з директорії composables/ і інкапсулюють реактивний стан разом з логікою роботи з ним.

Теорія

TL;DR

  • Composable - як інструментарій на полиці: береш useCounter() будь-де в застосунку, він приносить власний стан і методи, жодного import не потрібно.
  • Головна різниця від звичайних Vue-хуків: Nuxt автоматично імпортує їх під час збирання, а useState забезпечує SSR-сумісний спільний стан, що не скидається при навігації.
  • Використовуй composables коли логіка повторюється в 3+ компонентах. Для одного компонента - ref/computed прямо в коді.
  • Nuxt сканує composables/ при збиранні і додає кожен іменований експорт до .nuxt/imports.d.ts.

Базовий приклад

typescript
// composables/useCounter.ts export const useCounter = () => { const count = useState('counter', () => 0) // SSR-сумісний спільний стан з ключем const increment = () => count.value++ const decrement = () => count.value-- return { count, increment, decrement } }
vue
<!-- pages/index.vue — жодного import у файлі --> <script setup> const { count, increment } = useCounter() </script> <template> <div>Count: {{ count }} <button @click="increment()">+</button></div> </template>

useState('counter', ...) створює реактивний ref прив'язаний до ключа 'counter'. На сервері цей ключ зберігається в окремому сховищі для кожного запиту, щоб стан не просочувався між користувачами. На клієнті значення зберігається між переходами між сторінками. Скільки б компонентів не викликали useCounter(), всі вони працюють з одним реактивним ref.

Головна різниця від звичайних Vue-composables

Звичайний Vue-composable - це просто функція, яку пишеш і імпортуєш вручну. Nuxt додає до цього два механізми: автоматичний імпорт (Vite-плагін вставляє потрібні імпорти в кожен компонент під час збирання) і useState, який з'єднує SSR і клієнт. Звичайний ref всередині composable є локальним для компонента, що його викликав. useState з унікальним рядковим ключем - спільний для всіх компонентів і не скидається при клієнтській навігації.

Коли використовувати

  • Однотипна логіка fetch на кількох сторінках: composable замість копіювання $fetch-викликів.
  • Спільний стан застосунку - авторизація, тема оформлення: useState-composable перед тим, як братися за Pinia.
  • Логіка одного компонента: залиш ref/computed прямо там. Composable на 10 рядків для одного computed - це зайве.
  • Плагіни і middleware з реактивним станом: composables там теж працюють.

Pinia потрібна коли є нормалізований глобальний стан, до якого звертаються непов'язані частини застосунку. Для самодостатньої логіки composable простіший і достатній.

Як працює автоматичний імпорт

Vite-плагін Nuxt сканує директорію composables/ під час збирання і генерує карту авто-імпортів у .nuxt/imports.d.ts. Кожен файл у директорії і кожен іменований експорт стають глобально доступними у компонентах і сторінках. TypeScript підхоплює типи автоматично, autocomplete в редакторі працює, і жодного import-рядка у файлах компонентів.

Під час SSR useState зберігає значення в Map, прив'язаній до поточного запиту. На клієнті проксі реактивності Vue гарантують, що будь-яке оновлення ref спрацьовує в усіх компонентах, які його читають. Без prop drilling, без event bus.

Типові помилки

1. Забуте .value в useState

typescript
// Неправильно const count = useState('count', () => 0) setTimeout(() => count = 5, 1000) // ❌ замінює ref-об'єкт, реактивності немає // Правильно setTimeout(() => count.value = 5, 1000) // ✅

useState повертає ref. Пряме присвоєння замінює сам об'єкт ref, а не його внутрішнє значення. Жодне оновлення інтерфейсу не відбудеться.

2. Composable як синглтон

typescript
// Неправильно export const useConfig = () => { const config = useState('config', () => fetchConfig()) // ❌ фабрична функція синхронна, async-логіка тут не місце return { config } }

Фабрична функція в useState виконується лише один раз на ключ, але вона синхронна. Якщо помістити туди асинхронну логіку таким чином, отримаєш непередбачувану поведінку. Використовуй useLazyAsyncData або ініціалізуй дані в onMounted.

3. Доступ до браузерних API на верхньому рівні

typescript
// Неправильно export const useStorage = () => { const data = ref(localStorage.getItem('key')) // ❌ localStorage немає на сервері return { data } } // Правильно export const useStorage = () => { const data = ref(null) if (process.client) { data.value = localStorage.getItem('key') } return { data } }

SSR рендерить компонент на сервері першим. localStorage там не існує. Сервер рендерить null, клієнт рендерить значення - і ти отримуєш помилку гідратації (hydration mismatch). Захищай через process.client або вирівнюй початкові значення через useState.

4. Пропущена очистка слухачів подій

typescript
// Неправильно export const useWindowSize = () => { const width = ref(window.innerWidth) window.addEventListener('resize', () => width.value = window.innerWidth) // ❌ новий слухач додається при кожній навігації return { width } } // Правильно 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 } }

Без очистки п'ять переходів між сторінками - п'ять слухачів, що спрацьовують при кожній зміні розміру. Це помилка, яку знаходять у продакшені, а не на code review.

Де зустрічається в реальних проектах

  • Nuxt Auth module: useUser() ділить об'єкт сесії між layouts, сторінками і middleware.
  • Nuxt UI: useTabs() керує станом вкладок у вкладених компонентах без підняття стану вгору.
  • Nuxt Content v2: useAsyncData() завантажує markdown-контент з ключами кешування.
  • Supabase + Nuxt: useSupabaseUser() повертає реактивний стан авторизації, що оновлюється при вході і виході.

Питання на співбесіді

Q: Чим useState відрізняється від ref у composable?
A: ref локальний для компонента, що викликав composable. useState з рядковим ключем є спільним для всіх компонентів і зберігається між переходами на клієнті. На сервері кожен запит отримує власне ізольоване сховище, щоб стан не просочувався між користувачами.

Q: Що відбудеться, якщо два composables змінюють один і той самий ключ useState?
A: Перемагає останній запис. Обидва composables мають посилання на один реактивний ref, тому будь-яка зміна з будь-якого composable одразу поширюється на всі компоненти, що його читають.

Q: Як точно працює механізм автоматичного імпорту?
A: Nuxt сканує composables/ при збиранні і генерує .nuxt/imports.d.ts. Vite-плагін вставляє потрібні імпорти у кожен файл компонента автоматично. Ти не пишеш import вручну, але він є після збирання.

Q: Як зробити composable тільки для клієнта?
A: Обгорни браузерний код у if (process.client) { ... }. Або виклич composable всередині onMounted() після гідратації. Для асинхронних даних useLazyAsyncData відкладає виконання до клієнта.

Q (senior рівень): З composable приходить помилка гідратації. Як дебажити?
A: Спочатку перевір, чи немає у composable доступу до клієнтських API (localStorage, window, document) на верхньому рівні без захисту. Потім перевір, чи збігаються ключі useState між серверним і клієнтським рендерингом. Переглянь шар Nitro storage якщо composable використовує серверне збереження. Захисти через process.client або заміна ref на useState зазвичай вирівнюють початкові значення на обох сторонах.

Приклади

Спільний лічильник між сторінками

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 і pages/about.vue обидві викликають useCounter() --> <script setup> const { count, increment } = useCounter() </script> <template> <button @click="increment">Count: {{ count }}</button> </template>

Перейди з /home на /about. Лічильник зберігає значення, бо useState('counter', ...) використовує однаковий ключ на обох сторінках. Обидва компоненти працюють з одним реактивним ref без жодного store.

Завантаження профілю користувача в дашборді

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">Завантаження...</div> <div v-else-if="error">{{ error }}</div> <UserCard v-else :user="profile" /> </template>

Будь-яка сторінка, якій потрібен профіль користувача, викликає useUserProfile() і отримує готовий fetch/loading/error-патерн. Dashboard.vue, ProfilePage.vue і AdminView.vue використовують один composable замість того, щоб дублювати логіку в кожному файлі.

Слухач розміру вікна з очисткою

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>Ширина вікна: {{ width }}px</div> </template>

Захист через process.client запобігає доступу до window під час SSR. onBeforeUnmount видаляє слухач коли компонент знищується, тому навігація між сторінками не накопичує зайвих слухачів. Версія без очистки - класичний приклад запитання на співбесіді про витоки пам'яті (memory leaks) в Nuxt.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?