La Composition API dio a Vue algo que antes costaba conseguir con mixins: reutilizar lógica con estado sin ensuciar el componente ni arriesgarse a colisiones de nombres. Pero un composable mal diseñado es tan difícil de mantener como un mixin mal diseñado. Estos son los patrones que uso de forma consistente.

1. Devuelve siempre un objeto, no un array (salvo excepciones claras)

Con un objeto, quien consume el composable puede desestructurar solo lo que necesita y con nombres explícitos. Reservo el array solo para casos tipo useToggle(), donde el orden es obvio (como useState de React):

// bien: la intención es explícita en cada llamada
const { data, error, isLoading, refetch } = useFetch(url)

// solo para composables muy pequeños y con orden obvio
const [isOpen, toggle] = useToggle()

2. useFetch: estados explícitos, no solo un booleano

Un error común es modelar la carga con un único isLoading y olvidar el estado de error. Uso siempre tres estados explícitos y un AbortController para cancelar peticiones obsoletas cuando cambia el parámetro de entrada:

import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const isLoading = ref(false)

  watchEffect(async (onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    isLoading.value = true
    error.value = null

    try {
      const res = await fetch(toValue(url), { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e) {
      if (e.name !== 'AbortError') error.value = e
    } finally {
      isLoading.value = false
    }
  })

  return { data, error, isLoading }
}

La clave está en toValue(url): acepta tanto un string plano como un ref o una función, así el composable reacciona automáticamente si la URL cambia (por ejemplo, un ID de producto que depende de la ruta).

3. Composables de formulario: valida en el composable, no en el componente

Cuando el mismo formulario se repite en varias pantallas (alta de cliente, edición de cliente), centralizo el estado y la validación en un composable, y el componente solo se preocupa de pintar:

export function useClienteForm(initial = {}) {
  const form = reactive({ nombre: '', email: '', ...initial })
  const errors = reactive({})

  function validate() {
    errors.nombre = form.nombre.trim() ? '' : 'El nombre es obligatorio'
    errors.email = /\S+@\S+\.\S+/.test(form.email) ? '' : 'Email no válido'
    return !Object.values(errors).some(Boolean)
  }

  return { form, errors, validate }
}

4. Estado compartido entre componentes: sácalo del composable

Un error frecuente es esperar que dos componentes que llaman al mismo composable compartan estado. No lo hacen: cada llamada crea su propio ref aislado. Si necesitas estado realmente compartido (por ejemplo, un carrito de compra), el estado reactivo debe crearse fuera de la función, a nivel de módulo, y el composable solo expone la interfaz:

// carrito.js — el estado vive a nivel de módulo, no dentro de la función
const items = reactive([])

export function useCarrito() {
  function add(producto) {
    items.push(producto)
  }

  const total = computed(() =>
    items.reduce((sum, item) => sum + item.precio, 0)
  )

  return { items, add, total }
}

Ahora cualquier componente que importe useCarrito() ve y modifica el mismo estado, porque items se creó una única vez al cargar el módulo.

5. Limpieza explícita con onUnmounted

Cualquier composable que registre un listener, un timer o una suscripción debe limpiarlo. Es el fallo más habitual que veo en código de terceros:

export function useWindowResize() {
  const width = ref(window.innerWidth)

  function onResize() {
    width.value = window.innerWidth
  }

  onMounted(() => window.addEventListener('resize', onResize))
  onUnmounted(() => window.removeEventListener('resize', onResize))

  return { width }
}

Cómo los testeo

Un composable bien diseñado se testea sin montar ningún componente, usando @vue/test-utils o directamente llamándolo dentro de un effectScope() para poder limpiar los efectos después del test. Si tu composable necesita un componente real para poder probarlo, normalmente es señal de que está haciendo demasiadas cosas a la vez.