Skip to content

13. Composables

Un composable es una función que aprovecha la Composition API de Vue 3 para encapsular y reutilizar lógica con estado. Los composables permiten extraer y reutilizar lógica entre componentes sin las limitaciones de las mezclas (mixins) o los componentes de orden superior (HOCs) que se usaban en Vue 2.

Características principales de los composables

Section titled “Características principales de los composables”
  • Funciones JavaScript puras: Los composables son simplemente funciones que siguen una convención de nomenclatura.
  • Encapsulamiento de lógica: Permiten agrupar código relacionado en una unidad cohesiva.
  • Estado reactivo: Pueden crear y manipular estado reactivo usando ref, reactive, etc.
  • Composición: Pueden componerse entre sí, llamando a otros composables.
  • Convención de nombres: Por convención, los nombres de los composables comienzan con use (por ejemplo, useCounter).
Ejemplo básico de un composable
// useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
// Estado reactivo encapsulado
const count = ref(initialValue)
// Funciones para manipular el estado
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
// Exponer estado y métodos
return {
count,
increment,
decrement,
reset
}
}
Uso de un composable en un componente
<template>
<div>
<p>Contador: {{ count }}</p>
<button @click="increment">Incrementar</button>
<button @click="decrement">Decrementar</button>
<button @click="reset">Reiniciar</button>
</div>
</template>
<script setup>
import { useCounter } from './composables/useCounter'
// Usar el composable
const { count, increment, decrement, reset } = useCounter(10)
</script>

La convención de nombrar los composables con el prefijo use ayuda a identificarlos fácilmente y sigue el patrón establecido por React Hooks, aunque los composables de Vue tienen algunas diferencias importantes.

Composable useFetch
// useFetch.js
import { ref, reactive, computed } from 'vue'
export function useFetch(url, options = {}) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
const status = reactive({
code: 0,
text: '',
ok: false
})
const statusText = computed(() => status.code + ': ' + status.text)
const execute = async (newUrl = url, newOptions = {}) => {
loading.value = true
error.value = null
data.value = null
try {
const response = await fetch(newUrl, { ...options, ...newOptions })
// Actualizar estado
status.code = response.status
status.text = response.statusText
status.ok = response.ok
if (response.ok) {
data.value = await response.json()
} else {
error.value = new Error('Error en la petición: ' + response.status)
}
} catch (err) {
error.value = err
} finally {
loading.value = false
}
return { data, error, loading }
}
// Ejecutar la petición inmediatamente si se proporciona una URL
if (url) {
execute()
}
return {
data,
error,
loading,
status,
statusText,
execute
}
}

Una práctica común en proyectos Vue 3 es organizar los composables en una carpeta dedicada llamada composables/. Esto facilita la localización y reutilización de la lógica compartida.

src/
├── assets/
├── components/
├── composables/ # Carpeta para composables
│ ├── index.js # Exportaciones centralizadas (opcional)
│ ├── useCounter.js
│ ├── useFetch.js
│ ├── useLocalStorage.js
│ └── useWindowSize.js
├── router/
├── store/
└── views/

Archivo index.js para exportaciones centralizadas

Section titled “Archivo index.js para exportaciones centralizadas”

Puedes crear un archivo index.js en la carpeta composables/ para centralizar las exportaciones:

Exportaciones centralizadas
// composables/index.js
export { useCounter } from './useCounter'
export { useFetch } from './useFetch'
export { useLocalStorage } from './useLocalStorage'
export { useWindowSize } from './useWindowSize'
// Esto permite importar así:
// import { useCounter, useFetch } from '@/composables'

Para proyectos más grandes, puedes organizar los composables por dominios o funcionalidades:

src/composables/
├── auth/
│ ├── index.js
│ ├── useAuth.js
│ └── usePermissions.js
├── ui/
│ ├── index.js
│ ├── useBreakpoints.js
│ └── useTheme.js
└── utils/
├── index.js
├── useDebounce.js
└── useEventBus.js

Uso de ref, watch, computed dentro de composables

Section titled “Uso de ref, watch, computed dentro de composables”

Los composables pueden utilizar todas las APIs de la Composition API de Vue, como ref, reactive, computed, watch, y los hooks de ciclo de vida.

Uso de ref y reactive
// useForm.js
import { ref, reactive } from 'vue'
export function useForm(initialValues = {}) {
// Usando ref para valores simples
const isSubmitting = ref(false)
const isValid = ref(true)
// Usando reactive para objetos complejos
const formData = reactive({ ...initialValues })
const errors = reactive({})
const resetForm = () => {
Object.keys(formData).forEach(key => {
formData[key] = initialValues[key] || ''
})
Object.keys(errors).forEach(key => {
delete errors[key]
})
isValid.value = true
}
return {
formData,
errors,
isSubmitting,
isValid,
resetForm
}
}
Uso de computed
// useCart.js
import { ref, computed } from 'vue'
export function useCart() {
const items = ref([])
// Propiedades computadas
const totalItems = computed(() => items.value.length)
const totalPrice = computed(() => {
return items.value.reduce((total, item) => {
return total + (item.price * item.quantity)
}, 0)
})
const isEmpty = computed(() => items.value.length === 0)
function addItem(item) {
const existingItem = items.value.find(i => i.id === item.id)
if (existingItem) {
existingItem.quantity += 1
} else {
items.value.push({ ...item, quantity: 1 })
}
}
function removeItem(itemId) {
items.value = items.value.filter(item => item.id !== itemId)
}
return {
items,
totalItems,
totalPrice,
isEmpty,
addItem,
removeItem
}
}
Uso de watch y watchEffect
// useSearchQuery.js
import { ref, watch, watchEffect } from 'vue'
export function useSearchQuery(initialQuery = '') {
const searchQuery = ref(initialQuery)
const results = ref([])
const isLoading = ref(false)
const error = ref(null)
// Observar cambios en searchQuery con un retraso
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery === oldQuery) return
isLoading.value = true
error.value = null
try {
// Simular una llamada a API
await new Promise(resolve => setTimeout(resolve, 300))
results.value = ['Resultado para: ' + newQuery, 'Otro resultado']
} catch (err) {
error.value = err
results.value = []
} finally {
isLoading.value = false
}
}, { debounce: 300 })
// watchEffect se ejecuta inmediatamente y luego cuando cambian las dependencias
watchEffect(() => {
console.log('Búsqueda actual: ' + searchQuery.value + ', Resultados: ' + results.value.length)
})
return {
searchQuery,
results,
isLoading,
error
}
}
Uso de hooks de ciclo de vida
// usePageVisibility.js
import { ref, onMounted, onUnmounted } from 'vue'
export function usePageVisibility() {
const isVisible = ref(true)
const handleVisibilityChange = () => {
isVisible.value = !document.hidden
}
onMounted(() => {
// Registrar el evento cuando el componente se monta
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onUnmounted(() => {
// Limpiar el evento cuando el componente se desmonta
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return {
isVisible
}
}

Una de las ventajas más poderosas de los composables es que pueden componerse entre sí:

Composición de composables
// useUserProfile.js
import { computed } from 'vue'
import { useLocalStorage } from './useLocalStorage'
import { useFetch } from './useFetch'
export function useUserProfile() {
// Usar otros composables
const { value: userId } = useLocalStorage('user_id')
const { data: user, loading, error, execute: fetchUser } = useFetch(null)
// Computar propiedades basadas en datos de otros composables
const isLoggedIn = computed(() => !!userId.value)
const username = computed(() => user.value?.username || 'Invitado')
// Cargar perfil de usuario
const loadProfile = async () => {
if (userId.value) {
await fetchUser('/api/users/' + userId.value)
}
}
return {
user,
loading,
error,
isLoggedIn,
username,
loadProfile
}
}
🐝