Как писать собственные composables

Composable — это просто функция, которая использует Composition API, чтобы вынести и переиспользовать кусок логики с состоянием.

Composable — функция с именем по конвенции use*, которая инкапсулирует реактивное состояние и связанную с ним логику, чтобы её можно было применять в любом компоненте.

Раньше мы выносили общую логику в миксины и хелперы. У миксинов был хронический недуг: непрозрачный источник свойств (откуда взялась эта переменная в шаблоне?) и конфликты имён — два миксина легко затирали поля друг друга. Composables решают обе проблемы — это обычные функции, вы видите ровно то, что они вернули, и сами даёте имена. Нет скрытой инъекции свойств, нет «магии» — есть явный вызов и явный результат.

Ещё одно преимущество — тестируемость. Composable можно протестировать почти как чистую функцию: вызвать, проверить возвращённые ref, дёрнуть метод, убедиться в новом значении. Не нужно монтировать целый компонент. Это резко удешевляет юнит-тесты бизнес-логики.

Зачем это на практике

Представьте, что в трёх разных компонентах вам нужно отслеживать положение курсора мыши. Копировать ref, обработчик и подписку в каждый компонент — путь к расхождениям. Вместо этого пишем одну функцию useMouse() и зовём её там, где нужно. Каждый вызов создаёт свой экземпляр состояния, изолированный от других.

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

const { x, y } = useMouse()
</script>

<template>
  <p>Курсор: {{ x }}, {{ y }}</p>
</template>

Конвенция именования

Имя composable всегда начинается с use и пишется в camelCase: useMouse, useFetch, useDark. Это не прихоть — по этому префиксу читатель кода мгновенно понимает, что перед ним composable, который может содержать реактивное состояние и эффекты жизненного цикла. Линтеры и инструменты тоже опираются на эту конвенцию.

Сравните с обычной утилитой вроде formatDate(date): она не держит состояния и не привязана к жизненному циклу, поэтому префикс use ей не нужен. Само наличие use сигналит: «эту функцию зови внутри setup, она может подписываться на события и хранить реактивные данные». Так конвенция работает как лёгкая документация прямо в имени.

Что возвращать

Здесь есть важная развилка. Возвращайте объект из ref-ов, а не реактивный объект через reactive. Причина — деструктуризация. Если вы вернёте reactive({ x, y }) и деструктурируете его на стороне вызова, реактивность потеряется. А отдельные ref можно спокойно разложить: каждый сохраняет связь с источником.

function useCounter() {
  let count = 0
  const state = { count }
  state.inc = () => { state.count += 1; return state.count }
  return state
}

const c = useCounter()
console.log(c.inc())
console.log(c.inc())
console.log('итог:', c.count)

Вывод:

1
2
итог: 2

Это упрощённая иллюстрация формы «состояние + методы в одном объекте» на чистом JS. В настоящем Vue вместо обычных полей будут ref, но принцип тот же: composable отдаёт и данные, и функции для их изменения.

Реактивные аргументы и toValue

Хороший composable принимает аргументы, которые могут быть как обычным значением, так и ref или геттером. Тогда его можно «подвязать» к реактивному источнику. Ключ к этому — функция toValue() из Vue: она нормализует вход — разворачивает ref, вызывает геттер, а простое значение отдаёт как есть.

<script setup>
import { ref, watchEffect, toValue } from 'vue'

function useDoubled(source) {
  const result = ref(0)
  watchEffect(() => {
    result.value = toValue(source) * 2
  })
  return result
}

const n = ref(5)
const doubled = useDoubled(n)   // реактивно: меняем n — пересчитается
const fixed = useDoubled(10)    // статично: число 10
</script>

Оборачивание в watchEffect важно: оно автоматически перечитывает toValue(source) при изменении источника, поэтому useDoubled(n) реагирует на правки n.

Очистка эффектов

Любой composable, который что-то «подписывает» (слушатели событий, таймеры, веб-сокеты), обязан это убирать. Иначе при размонтировании компонента подписка останется висеть — это утечка памяти и фантомные срабатывания. Используйте onUnmounted или функцию очистки внутри watchEffect.

<script setup>
import { ref, onUnmounted } from 'vue'

function useInterval(callback, delay) {
  const id = setInterval(callback, delay)
  onUnmounted(() => clearInterval(id))
  return { stop: () => clearInterval(id) }
}
</script>

Очистка внутри watchEffect

Когда эффект пересоздаётся (например, поменялся URL для подписки), нужно убрать предыдущую подписку перед созданием новой. Для этого watchEffect даёт функцию onCleanup:

<script setup>
import { watchEffect, toValue } from 'vue'

function useSocket(urlSource) {
  watchEffect((onCleanup) => {
    const ws = new WebSocket(toValue(urlSource))
    onCleanup(() => ws.close())
  })
}
</script>

Как это работает под капотом

Никакой магии: composable вызывается синхронно внутри setup (или <script setup>). В этот момент активен «текущий экземпляр компонента», и хуки вроде onMounted, onUnmounted регистрируются именно на него. Поэтому composable нельзя вызывать асинхронно (после await) или внутри обработчика события — там контекст компонента уже потерян, и хуки молча не сработают. Реактивные ref внутри composable — те же обычные ref; каждый вызов функции создаёт новый набор, отсюда и изоляция между компонентами.

Частые ошибки

  • Возвращать reactive(...) и деструктурировать на вызове — реактивность теряется. Возвращайте объект из ref.
  • Забыть очистку подписок/таймеров — утечки памяти и срабатывания после размонтирования.
  • Читать аргумент-ref как source.value вместо toValue(source) — тогда composable не примет геттер и не будет реагировать на смену источника.
  • Вызывать composable внутри обработчика или после await — хуки жизненного цикла не привяжутся.
  • Класть в один composable слишком много несвязанного — лучше несколько маленьких, которые комбинируются.

Итоги

  • Composable — функция use*, инкапсулирующая реактивное состояние и логику для переиспользования.
  • Возвращайте объект из ref, чтобы его можно было безопасно деструктурировать.
  • Принимайте аргументы через toValue, чтобы поддержать и значения, и ref, и геттеры.
  • Всегда чистите эффекты в onUnmounted или через onCleanup в watchEffect.
  • Вызывайте composables синхронно в setup — только там доступен контекст компонента.
Проверьте себя
1. Почему из composable рекомендуют возвращать объект из ref, а не reactive(...)?
Areactive медленнее работает
BОбъект из ref можно деструктурировать на стороне вызова без потери реактивности
Creactive нельзя использовать в <script setup>
Dref занимает меньше памяти
2. Зачем в composable оборачивать аргумент в toValue()?
AЧтобы превратить значение в строку
BЧтобы composable принимал и обычное значение, и ref, и геттер единообразно
CЭто обязательно для любого ref
DЧтобы отключить реактивность аргумента
3. Что произойдёт, если composable с onMounted вызвать асинхронно после await?
AХук onMounted не привяжется к компоненту и не сработает
BVue выбросит фатальную ошибку и остановит рендер
CХук сработает дважды
DНичего, это полностью корректно