Привет, Хабр!

Сегодня рассмотрим, что такое композиционные хуки во Vue 3, зачем они нужны и как их использовать.

Что вообще за хуки во Vue 3?

Хук (composable) во Vue 3 — это обычная функция, которая живёт внутри setup() или другого хука и использует возможности Composition API: ref, reactive, computed, watch, жизненные циклы, provide/inject.

Но не обманывайтесь простотой. Это целый способ инкапсуляции реактивного поведения, привязанного к жизненному циклу компонента, но изолированного от его UI-структуры.

Определение, которое стоит запомнить:

Композиционный хук — это чистая функция, создающая реактивное поведение и управляющая побочными эффектами, синхронно с жизненным циклом компонента.

Основные свойства хуков:

  • Работают только в setup(): иначе потеряете реактивность.

  • Живут в реактивной области компонента — значит, все ref и reactive внутри них участвуют в трекинге зависимостей.

  • Могут использовать жизненные циклы (onMounted, onUnmounted и др.).

  • Могут вызываться несколько раз: каждый вызов — изолированное состояние.

  • Обязаны убирать за собой, если создают сайд-эффекты (например, подписки или таймеры).

Анатомия композиционного хука

Вот шаблон, который лежит в основе любого хорошего хука:

import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  // 1. Приватное реактивное состояние
  const x = ref(0)
  const y = ref(0)

  // 2. Бизнес-логика, отделённая от UI
  const update = (e: MouseEvent) => {
    x.value = e.pageX
    y.value = e.pageY
  }

  // 3. Сайд-эффекты, завязанные на жизненный цикл
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 4. Публичный контракт (API наружу)
  return { x, y }
}

Каждый блок здесь имеет свою задачу:

Слой

Зачем он нужен

1

ref/reactive

Создаём локальный state — строго внутри функции, не снаружи

2

Отдельные методы (update)

Логика вынесена, можно тестировать и подменять

3

onMounted/onUnmounted

Вешаем и снимаем слушатели.

4

return {}

Только нужное наружу.

Если коротко: хук = функция, которая подключается к реактивной системе Vue в момент вызова из setup(). Вся логика хука работает так, будто ты написал её прямо в setup(). Но — чище, модульнее и повторно используемо.

И это весь смысл композиционного API: разделить поведение, оставить компоненту только структуру.

Производственный useFetch: отменяем, кешируем, типизируем

На практике, самый частый use case для хуков — это вытаскивание данных. Всё, что приходит по сети — API-запросы, загрузка списков, профилей, карточек — должно быть вынесено в отдельный слой.

Создадим useFetch(), который умеет:

  • безопасно отменять висящие запросы при unmounted,

  • оборачивать запрос в try/catch/finally,

  • кастомизироваться под тесты и заглушки через fetcher,

  • типизироваться через T,

  • таймаутить медленные ответы,

  • опционально кешировать данные локально.

Скелет useFetch()

// useFetch.ts
import { ref, shallowRef, onUnmounted } from 'vue'

interface Options<T> {
  cacheKey?: string
  fetcher?: (url: string) => Promise<T>   // можно подменить на тестах
  immediate?: boolean
  timeout?: number
}

export function useFetch<T = unknown>(url: string, opts: Options<T> = {}) {
  const data     = shallowRef<T | null>(null)
  const error    = ref<Error | null>(null)
  const loading  = ref(false)
  const controller = new AbortController()
  let timer: number | undefined

ref для loading и error, как обычно. shallowRef для data — чтобы Vue не превращал вложенные объекты в реактивные (например, если это массивы с 10K элементов). AbortController — чтобы можно было отменить запрос, если компонент анмаунтится или timeout наступает. Options — удобный API: можно передать fetcher, можно не делать immediate, можно задать timeout.

Дальше — реализация запроса:

  const fetcher = opts.fetcher ?? (u => fetch(u, { signal: controller.signal }).then(r => {
    if (!r.ok) throw new Error(`HTTP ${r.status}`)
    return r.json() as Promise<T>
  }))

Если fetcher не передан — используем стандартный fetch, но уже с сигналом для отмены.

Основная логика запроса:

  const exec = async () => {
    loading.value = true
    error.value = null
    try {
      if (opts.timeout)
        timer = window.setTimeout(() => controller.abort(), opts.timeout)

      data.value = await fetcher(url)
    } catch (e) {
      if ((e as DOMException).name !== 'AbortError')
        error.value = e as Error
    } finally {
      loading.value = false
      clearTimeout(timer)
    }
  }

Перед началом — сбрасываем error, включаем loading, если задан timeout, вешаем setTimeout, который через N миллисекунд вызовет abort() (всё это прерывает fetch).

Если ошибка — проверяем, не AbortError ли это, чтобы не засорять error лишним, а в finally — выключаем loading, убираем таймер.

Это костяк для 99% асинхронных операций в UI: fetch + abort + timeout + loading/error guard.

Подключение хуку к жизненному циклу:

  if (opts.immediate !== false) exec()
  onUnmounted(() => controller.abort())

Если immediate !== false, то запрос начнётся сразу. Если нет — компонент сам вызовет refetch() позже.

onUnmounted(() => controller.abort()) — это страховка: уходит компонент — уходит запрос. Иначе можно словить ошибку обновления state на уничтоженном компоненте, или вообще race condition.

Экспортируем API:

  return {
    data,
    error,
    loading,
    refetch: exec,
    abort: () => controller.abort(),
    canAbort: () => !controller.signal.aborted
  }
}

refetch() — можно повторно загрузить вручную; abort() — вручную прервать (например, пользователь нажал "Отмена"); canAbort() — полезно в UI (например, серый vs активный "Отмена").

Подключаем кеш

Кеширование по cacheKey — элементарный, но рабочий паттерн.

const cached = new Map<string, unknown>()

if (opts.cacheKey && cached.has(opts.cacheKey)) {
  data.value = cached.get(opts.cacheKey) as T
} else {
  // после успешного запроса:
  if (opts.cacheKey) cached.set(opts.cacheKey, data.value)
}

Зачем это? 80% фронтов живёт на временном кешировании:

  • чтобы не дёргать API лишний раз;

  • чтобы при повторных переходах не было "моргания" загрузки;

  • чтобы давать мгновенный отклик при возвращении на предыдущий экран.

Можно улучшить:

  • TTL через Map<string, { data, timestamp }>;

  • LRU кеширование;

  • проброс cachePolicy: 'cache-first' | 'network-only'.

Но даже простейший Map покрывает 90% задач.

Масштабируем хуки, управляем скоупом и выходим за пределы setup()

Когда вы уже наловчились писать свои useFetch, useCounter и useMouse, приходит пора следующего уровня. Хуки становятся сложнее: они работают с WebSocket'ами, обмениваются состоянием между компонентами, шарят глобальный auth, или лезут вглубь Vue-инстанса.

Управляем областью реактивности через effectScope

По дефолту все ref, computed, watchEffect и даже onUnmounted внутри хука регистрируются во внешнем скоупе компонента. И это нормально, пока вы не создаёте несколько реактивных зависимостей, которые нужно убить одной командой.

В таких случаях нужен effectScope — встроанный в Vue механизм, позволяющий запускать реактивный контекст в изолированной области, которую вы можете вручную останавливать. Это хороший способ собрать все сайд-эффекты в песочницу и уничтожить её вызовом scope.stop().

Реализация useWebSocket с effectScope и авто-reconnect

Пример хука для WebSocket. Он:

  • создаёт соединение,

  • ловит message и пушит в реактивный messages,

  • восстанавливает соединение при обрыве,

  • использует effectScope, чтобы не протекли подписки.

// useWebSocket.ts
import { ref, effectScope, onUnmounted } from 'vue'

export function useWebSocket(url: string) {
  const scope = effectScope()
  const messages = ref<string[]>([])
  const status   = ref<'OPEN' | 'CLOSED' | 'ERROR'>('CLOSED')
  let ws: WebSocket | null = null
  let retry = 0
  const MAX_RETRY = 5

  const init = () => {
    ws = new WebSocket(url)
    status.value = 'OPEN'

    ws.addEventListener('message', e => messages.value.push(e.data))
    ws.addEventListener('close', handleClose)
    ws.addEventListener('error', handleError)
  }

  const handleClose = () => reconnect()
  const handleError = () => reconnect()

  const reconnect = () => {
    status.value = 'ERROR'
    if (retry++ < MAX_RETRY) {
      setTimeout(init, 1000 * retry) // exponential back-off
    } else {
      console.warn('Max WS retries reached')
    }
  }

  scope.run(() => init()) // все сайд-эффекты пойдут внутрь scope

  const send = (msg: string) => ws?.readyState === WebSocket.OPEN && ws.send(msg)

  const stop = () => {
    ws?.close()
    scope.stop() // мгновенно отключает все эффекты, listeners и reactivity
  }

  onUnmounted(stop)

  return { messages, status, send, stop }
}

effectScope собирает:

  • все ref, чтобы они больше не обновлялись,

  • все watchEffect, если бы они были внутри,

  • все сайд-эффекты, подписки — и убирает их одним вызовом scope.stop().

Без этого, даже при onUnmounted, могли бы остаться живые WebSocket-обработчики.

Делимся состоянием глобально: provide / inject

Иногда компоненту нужно не просто вызвать хук, а разделить его состояние с другими компонентами. Например, логин, текущий пользователь, глобальные настройки.

Vue имеет чистый механизм — provide() и inject().

Вот как выглядит глобальный useAuth():

// authProvider.ts
import { provide, inject } from 'vue'
import { currentUser } from '@/stores/user' // глобальный ref

const key = Symbol('auth')

export function provideAuth() {
  provide(key, {
    user: currentUser,
    login: () => { /* ... */ },
    logout: () => { /* ... */ }
  })
}

export function useAuth() {
  const ctx = inject<ReturnType<typeof provideAuth>>(key)
  if (!ctx) throw new Error('Auth not provided')
  return ctx
}

Принцип:

  • в корневом компоненте (например, App.vue или Layout) вызываем provideAuth() один раз;

  • внутри любого дочернего — useAuth(), без пропсов и глобальных сторей.

Это работает без потери реактивности. То, что ты provide'нул, остаётся реактивным — Vue просто прокидывает ссылку через внутренний context tree.

getCurrentInstance()

Иногда нужен доступ к emit, proxy, appContext, attrs. Например, если вы пишете хук, который:

  • триггерит emit() изнутри;

  • работает с attrs или slots;

  • лезет в appContext.config.

Для этого есть getCurrentInstance():

import { getCurrentInstance } from 'vue'

export function useEmitter() {
  const inst = getCurrentInstance()
  if (!inst) throw new Error('useEmitter must be called in setup')
  const emit = inst.emit
  return { emit }
}

Вызываем только внутри setup() или хуков, иначе инстанса не будет. Также не стоит вызывать внутри computed или watch, потому что на момент повторного запуска — инстанс уже не доступен (частый баг, обсуждаемый на GitHub).

Тестируем composable

Если вы пишете хуки с логикой, таймерами, сетевыми запросами или shared state — вам придётся тестировать их.

Vue 3 делает это проще, чем кажется. Главное — помнить: хук сам по себе просто функция, которую можно замаунтить через setup() в мок-компонент и потестить.

Vitest + @vue/test-utils: базовая схема

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { useFetch } from '@/composables/useFetch'

describe('useFetch', () => {
  it('fetches data and exposes it', async () => {
    vi.stubGlobal('fetch', vi.fn(() =>
      Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: 1 }) })
    ))

    const Comp = defineComponent({
      setup () {
        return useFetch<{ ok: number }>('/api', { immediate: false })
      },
      template: '<div></div>'
    })

    const wrapper = mount(Comp)

    await wrapper.vm.refetch()
    expect(wrapper.vm.data.ok).toBe(1)
  })
})

Заглушаем fetch глобально через vi.stubGlobal. Далее создаём мок-компонент, который просто вызывает useFetch в setup()в этом вся фича: хук становится частью компонента, и мы можем получить к нему доступ через wrapper.vm.

Вызываем refetch(), ждём промис, и проверяем результат.

flushPromises(): не забываем про microtasks

Асинхронные хуки почти всегда требуют flushPromises():

import flushPromises from 'flush-promises'

await wrapper.vm.refetch()
await flushPromises()
expect(wrapper.vm.data).toEqual(...)

await только дождётся промиса — но Vue запланирует обновление реактивных данных в следующем тике. Без flushPromises() можно проверять ref, который ещё не обновился.

Вывод

Если вы уже пишете свои хуки — расскажите в комментариях, какие подходы у вас прижились. Что оказалось удобным, что — нет. Используете ли effectScope, делаете ли глобальные provide-хуки, тестируете ли логику? Делитесь своим опытом.


Погружаетесь в Vue 3 и хотите освоить современные подходы к разработке?

Разберитесь с композиционными хуками — они позволяют писать чистые, модульные функции с полной поддержкой реактивности и жизненного цикла. А чтобы не просто читать, а практиковаться под руководством экспертов — приходите на открытые уроки:

А ещё приглашаем пройти вступительное тестирование — это отличный способ включиться в процесс и сделать первый шаг к обучению.

Комментарии (1)


  1. devprodest
    01.07.2025 19:32

    Тот случай когда реактер добрался до вью...