En esta página

Composables — lógica reutilizable

14 min lectura TextoCap. 3 — Composición

¿Qué es un composable?

Un composable es una función que encapsula y reutiliza lógica de estado con uso de la Composition API de Vue. Son el equivalente de los Custom Hooks de React, pero con la ergonomía de Vue.

La idea es simple: si tienes lógica que involucra estado reactivo y podría usarse en múltiples componentes, extráela a una función con prefijo use:

// Antes — lógica duplicada en cada componente
// Componente A
const contador = ref(0)
function incrementar() { contador.value++ }

// Componente B (misma lógica duplicada)
const contador = ref(0)
function incrementar() { contador.value++ }

// ✅ Después — composable reutilizable
function useContador(inicial = 0) {
  const contador = ref(inicial)
  const incrementar = () => contador.value++
  const decrementar = () => contador.value--
  const reiniciar = () => { contador.value = inicial }
  return { contador, incrementar, decrementar, reiniciar }
}

Crear tu primer composable — useContador

// src/composables/useContador.ts
import { ref, computed, type Ref } from 'vue'

interface OpcionesContador {
  min?: number
  max?: number
  paso?: number
}

export function useContador(inicial = 0, opciones: OpcionesContador = {}) {
  const { min = -Infinity, max = Infinity, paso = 1 } = opciones

  const contador = ref(inicial)
  const estaEnMinimo = computed(() => contador.value <= min)
  const estaEnMaximo = computed(() => contador.value >= max)

  function incrementar(): void {
    if (!estaEnMaximo.value) {
      contador.value = Math.min(contador.value + paso, max)
    }
  }

  function decrementar(): void {
    if (!estaEnMinimo.value) {
      contador.value = Math.max(contador.value - paso, min)
    }
  }

  function reiniciar(): void {
    contador.value = inicial
  }

  return {
    contador,
    estaEnMinimo,
    estaEnMaximo,
    incrementar,
    decrementar,
    reiniciar,
  }
}

Uso en componente:

<script setup lang="ts">
import { useContador } from '@/composables/useContador'

const { contador, incrementar, decrementar, reiniciar, estaEnMinimo } =
  useContador(10, { min: 0, max: 100, paso: 5 })
</script>

<template>
  <div>
    <button type="button" @click="decrementar" :disabled="estaEnMinimo">-5</button>
    <span>{{ contador }}</span>
    <button type="button" @click="incrementar">+5</button>
    <button type="button" @click="reiniciar">Reiniciar</button>
  </div>
</template>

Lifecycle hooks en composables

Los composables pueden usar los lifecycle hooks de Vue. El hook se asocia al componente que llama al composable:

// src/composables/useWindowSize.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowSize() {
  const ancho = ref(window.innerWidth)
  const alto = ref(window.innerHeight)

  function actualizar(): void {
    ancho.value = window.innerWidth
    alto.value = window.innerHeight
  }

  onMounted(() => {
    window.addEventListener('resize', actualizar)
  })

  onUnmounted(() => {
    window.removeEventListener('resize', actualizar)
  })

  return { ancho, alto }
}

Cuando el componente que usa este composable se desmonta, el event listener se limpia automáticamente.

MaybeRefOrGetter — argumentos flexibles

Vue 3.3+ introdujo el tipo MaybeRefOrGetter<T> y la función toValue() para crear composables que aceptan tanto valores normales como refs o getters:

import { toValue, type MaybeRefOrGetter } from 'vue'

export function useFormatearFecha(
  fecha: MaybeRefOrGetter<Date | string>
) {
  return computed(() => {
    const valor = toValue(fecha) // Desenvuelve ref, llama getter, o devuelve valor directo
    const d = new Date(valor)
    return d.toLocaleDateString('es-ES', {
      day: '2-digit', month: 'long', year: 'numeric'
    })
  })
}

// Uso con valor directo
const formateada1 = useFormatearFecha('2026-04-02')

// Uso con ref — se actualiza automáticamente
const fechaSeleccionada = ref(new Date())
const formateada2 = useFormatearFecha(fechaSeleccionada)

// Uso con getter — se actualiza cuando props cambian
const formateada3 = useFormatearFecha(() => props.fechaCreacion)

Composable de ratón — useMousePosition

// src/composables/useMousePosition.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)

  function actualizar(evento: MouseEvent): void {
    x.value = evento.clientX
    y.value = evento.clientY
  }

  onMounted(() => document.addEventListener('mousemove', actualizar))
  onUnmounted(() => document.removeEventListener('mousemove', actualizar))

  return { x, y }
}

Composables y el principio de responsabilidad única

Un composable debe hacer una sola cosa bien. Si useFetch también gestiona la autenticación y el caché, es demasiado. Divide la responsabilidad:

// ✅ Responsabilidades separadas y compuestas
const { token } = useAuth()
const { datos, cargando } = useFetch(`/api/perfil`, { headers: { Authorization: `Bearer ${token.value}` } })
const { guardado } = useCache('perfil', datos)

Compartir estado entre componentes

Los refs y reactives dentro de un composable son únicos para cada llamada. Pero si necesitas estado compartido entre múltiples componentes sin Pinia, puedes crear el estado fuera de la función:

// Estado compartido — creado una sola vez fuera de la función
const temas = reactive({
  actual: 'claro' as 'claro' | 'oscuro',
})

// Todos los componentes que llaman useTheme comparten el mismo estado
export function useTema() {
  const toggleTema = () => {
    temas.actual = temas.actual === 'claro' ? 'oscuro' : 'claro'
  }

  return {
    tema: toRef(temas, 'actual'),
    toggleTema,
  }
}

Composables y el ecosistema — VueUse

VueUse es la librería oficial de composables utilitarios de la comunidad Vue. Incluye más de 200 composables para tareas comunes:

npm install @vueuse/core
import {
  useDark,              // Modo oscuro
  useLocalStorage,      // localStorage reactivo
  useClipboard,         // Portapapeles
  useGeolocation,       // Geolocalización
  useIntersectionObserver, // Intersection Observer
  useDebounce,          // Debounce
  useThrottle,          // Throttle
  useFetch,             // Fetch reactivo con abort
} from '@vueuse/core'

// Ejemplo de uso
const esModoOscuro = useDark()
const { copy, copied } = useClipboard()
const { coords } = useGeolocation()

Organización de composables

La convención estándar para proyectos Vue es:

src/
├── composables/
│   ├── useAuth.ts
│   ├── useFetch.ts
│   ├── useLocalStorage.ts
│   ├── useNotificaciones.ts
│   └── index.ts        # Re-exporta todos los composables

Exportar todo desde un index.ts permite imports más limpios:

import { useAuth, useFetch, useNotificaciones } from '@/composables'

Práctica

  1. useContador mejorado: Extiende el useContador del ejemplo para añadir un historial de valores con ref<number[]>([]). Añade funciones deshacer() y rehacerUltimo().
  2. useDebounce: Crea un composable useDebounce<T>(valor: MaybeRefOrGetter<T>, ms: number): Ref<T> que devuelva un ref que se actualiza con retraso. Úsalo en una búsqueda para no llamar a la API en cada tecla.
  3. useApi: Crea un composable genérico useApi<T>({ url, metodo, cuerpo }) que gestione el estado de carga, error y datos para peticiones HTTP. Añade una función ejecutar() para llamadas manuales (no automáticas con watchEffect).

En la siguiente lección aprenderemos provide() e inject() para compartir datos entre componentes distantes.

Convención de nombres — use como prefijo
Los composables siempre se nombran con el prefijo use: useContador, useFetch, useLocalStorage, useRouter. Esta convención indica que la función usa la API de Composición de Vue y puede contener estado reactivo. Las herramientas y el equipo de Vue reconocen esta convención para el análisis estático.
Composables vs mixins — una mejora sustancial
Los composables reemplazan a los mixins de Vue 2 con ventajas importantes: fuentes claras (sabes de dónde viene cada función/dato), sin colisión de nombres (las variables se nombran explícitamente al importar), y mejor soporte de TypeScript. Los mixins están deprecados en Vue 3.
Ejecuta composables en el contexto correcto
Los composables que usan lifecycle hooks (onMounted, onUnmounted) o provide/inject DEBEN ser llamados dentro de setup() o script setup, no dentro de funciones asíncronas ni fuera del contexto del componente. Composables que solo usan ref/watch/computed pueden llamarse en cualquier lugar.
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'

interface FetchState<T> {
  datos: T | null
  cargando: boolean
  error: string | null
}

export function useFetch<T>(url: MaybeRefOrGetter<string>) {
  const datos = ref<T | null>(null)
  const cargando = ref(false)
  const error = ref<string | null>(null)

  // watchEffect rastrea el URL — si es un ref, re-ejecuta al cambiar
  watchEffect(async (onCleanup) => {
    let cancelado = false
    onCleanup(() => { cancelado = true })

    const urlResuelta = toValue(url)
    if (!urlResuelta) return

    cargando.value = true
    error.value = null

    try {
      const res = await fetch(urlResuelta)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const json = await res.json() as T
      if (!cancelado) datos.value = json
    } catch (e) {
      if (!cancelado) {
        error.value = e instanceof Error ? e.message : 'Error desconocido'
      }
    } finally {
      if (!cancelado) cargando.value = false
    }
  })

  return { datos, cargando, error }
}
typescript
import { ref, watch, type Ref } from 'vue'

export function useLocalStorage<T>(
  clave: string,
  valorInicial: T
): Ref<T> {
  // Inicializar desde localStorage o usar el valor inicial
  const leer = (): T => {
    try {
      const guardado = localStorage.getItem(clave)
      return guardado !== null ? JSON.parse(guardado) as T : valorInicial
    } catch {
      return valorInicial
    }
  }

  const estado = ref<T>(leer()) as Ref<T>

  // Sincronizar automáticamente con localStorage
  watch(
    estado,
    (nuevo) => {
      try {
        localStorage.setItem(clave, JSON.stringify(nuevo))
      } catch {
        console.warn(`No se pudo guardar ${clave} en localStorage`)
      }
    },
    { deep: true }
  )

  return estado
}