Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке composables в Nuxt?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Composables в Nuxt** - це функції Vue Composition API, що автоматично імпортуються з директорії `composables/` і повертають реактивний стан та методи для спільного використання між компонентами. ```ts const { count, increment } = useCounter() // жодного import у компоненті ``` **Ключове:** `useState` всередині composable забезпечує SSR-сумісний стан, спільний між компонентами.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.