En esta página
Composables — lógica reutilizable
¿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/coreimport {
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 composablesExportar todo desde un index.ts permite imports más limpios:
import { useAuth, useFetch, useNotificaciones } from '@/composables'Práctica
- useContador mejorado: Extiende el
useContadordel ejemplo para añadir un historial de valores conref<number[]>([]). Añade funcionesdeshacer()yrehacerUltimo(). - 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. - 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ónejecutar()para llamadas manuales (no automáticas con watchEffect).
En la siguiente lección aprenderemos provide() e inject() para compartir datos entre componentes distantes.
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 }
}
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
}
Inicia sesión para guardar tu progreso