13. Composables
¿Qué es un Composable?
Section titled “¿Qué es un Composable?”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
Section titled “Ejemplo básico de un composable”// useCounter.jsimport { ref } from 'vue'
export function useCounter(initialValue = 0) {// Estado reactivo encapsuladoconst count = ref(initialValue)
// Funciones para manipular el estadofunction increment() { count.value++}
function decrement() { count.value--}
function reset() { count.value = initialValue}
// Exponer estado y métodosreturn { count, increment, decrement, reset}}Uso en un componente
Section titled “Uso 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 composableconst { count, increment, decrement, reset } = useCounter(10)</script>useX() - Funciones Reutilizables
Section titled “useX() - Funciones Reutilizables”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.
Ejemplos de composables comunes
Section titled “Ejemplos de composables comunes”// useFetch.jsimport { 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 URLif (url) { execute()}
return { data, error, loading, status, statusText, execute}}// useLocalStorage.jsimport { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue = null) {// Intentar recuperar el valor almacenadoconst storedValue = localStorage.getItem(key)
// Crear referencia reactiva con el valor almacenado o el valor predeterminadoconst value = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
// Sincronizar cambios con localStoragewatch(value, (newValue) => { if (newValue === null) { localStorage.removeItem(key) } else { localStorage.setItem(key, JSON.stringify(newValue)) }}, { deep: true })
// Función para eliminar el valorconst removeItem = () => { value.value = null}
return { value, removeItem}}// useWindowSize.jsimport { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {const width = ref(window?.innerWidth || 0)const height = ref(window?.innerHeight || 0)
const update = () => { width.value = window.innerWidth height.value = window.innerHeight}
onMounted(() => { window.addEventListener('resize', update) update() // Actualizar valores iniciales})
onUnmounted(() => { window.removeEventListener('resize', update)})
return { width, height}}Carpeta composables/
Section titled “Carpeta composables/”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.
Estructura de carpetas recomendada
Section titled “Estructura de carpetas recomendada”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:
// composables/index.jsexport { useCounter } from './useCounter'export { useFetch } from './useFetch'export { useLocalStorage } from './useLocalStorage'export { useWindowSize } from './useWindowSize'
// Esto permite importar así:// import { useCounter, useFetch } from '@/composables'Organización por dominios
Section titled “Organización por dominios”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.jsUso 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
Section titled “Uso de ref y reactive”// useForm.jsimport { ref, reactive } from 'vue'
export function useForm(initialValues = {}) {// Usando ref para valores simplesconst isSubmitting = ref(false)const isValid = ref(true)
// Usando reactive para objetos complejosconst 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
Section titled “Uso de computed”// useCart.jsimport { ref, computed } from 'vue'
export function useCart() {const items = ref([])
// Propiedades computadasconst 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
Section titled “Uso de watch y watchEffect”// useSearchQuery.jsimport { 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 retrasowatch(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 dependenciaswatchEffect(() => { console.log('Búsqueda actual: ' + searchQuery.value + ', Resultados: ' + results.value.length)})
return { searchQuery, results, isLoading, error}}Uso de hooks de ciclo de vida
Section titled “Uso de hooks de ciclo de vida”// usePageVisibility.jsimport { 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}}Composición de composables
Section titled “Composición de composables”Una de las ventajas más poderosas de los composables es que pueden componerse entre sí:
// useUserProfile.jsimport { computed } from 'vue'import { useLocalStorage } from './useLocalStorage'import { useFetch } from './useFetch'
export function useUserProfile() {// Usar otros composablesconst { value: userId } = useLocalStorage('user_id')const { data: user, loading, error, execute: fetchUser } = useFetch(null)
// Computar propiedades basadas en datos de otros composablesconst isLoggedIn = computed(() => !!userId.value)const username = computed(() => user.value?.username || 'Invitado')
// Cargar perfil de usuarioconst loadProfile = async () => { if (userId.value) { await fetchUser('/api/users/' + userId.value) }}
return { user, loading, error, isLoggedIn, username, loadProfile}}