Saltar al contenido

Composables

TIP

Esta sección asume conocimientos básicos de la Composition API. Si has estado aprendiendo Vue solo con la Options API, puedes establecer la Preferencia de API en Composition API (usando el interruptor en la parte superior de la barra lateral izquierda) y volver a leer los capítulos Fundamentos de Reactividad y Hooks del Ciclo de Vida.

¿Qué es un "Composable"?

En el contexto de las aplicaciones Vue, un "composable" es una función que aprovecha la Composition API de Vue para encapsular y reutilizar lógica con estado.

Al construir aplicaciones frontend, a menudo necesitamos reutilizar lógica para tareas comunes. Por ejemplo, es posible que necesitemos formatear fechas en muchos lugares, por lo que extraemos una función reutilizable para ello. Esta función de formateo encapsula lógica sin estado: toma una entrada e inmediatamente devuelve la salida esperada. Existen muchas librerías para reutilizar lógica sin estado, por ejemplo lodash y date-fns, de las que quizás hayas oído hablar.

Por el contrario, la lógica con estado implica gestionar un estado que cambia con el tiempo. Un ejemplo simple sería rastrear la posición actual del ratón en una página. En escenarios del mundo real, también podría ser una lógica más compleja, como gestos táctiles o el estado de conexión a una base de datos.

Ejemplo de Seguimiento del Ratón

Si implementáramos la funcionalidad de seguimiento del ratón utilizando la Composition API directamente dentro de un componente, se vería así:

MouseComponent.vue
vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Posición del ratón: {{ x }}, {{ y }}</template>

Pero, ¿qué pasa si queremos reutilizar la misma lógica en varios componentes? Podemos extraer la lógica a un archivo externo, como una función composable:

mouse.js
js
import { ref, onMounted, onUnmounted } from 'vue'

// Por convención, los nombres de las funciones composables comienzan con "use"
export function useMouse() {
  // estado encapsulado y gestionado por el composable
  const x = ref(0)
  const y = ref(0)

  // un composable puede actualizar su estado gestionado a lo largo del tiempo.
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // un composable también puede conectarse al ciclo de vida de su componente
  // propietario para configurar y desmontar efectos secundarios.
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // exponer el estado gestionado como valor de retorno
  return { x, y }
}

Y así es como se puede usar en los componentes:

MouseComponent.vue
vue
<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Posición del ratón: {{ x }}, {{ y }}</template>
Posición del ratón: 0, 0

Pruébalo en el Playground

Como podemos ver, la lógica central sigue siendo idéntica; todo lo que tuvimos que hacer fue moverla a una función externa y devolver el estado que debía exponerse. Al igual que dentro de un componente, puedes usar la gama completa de funciones de la Composition API en los composables. La misma funcionalidad useMouse() ahora puede usarse en cualquier componente.

La parte más interesante de los composables, sin embargo, es que también puedes anidarlos: una función composable puede llamar a una o más funciones composable. Esto nos permite componer lógica compleja utilizando unidades pequeñas y aisladas, de manera similar a cómo componemos una aplicación completa utilizando componentes. De hecho, esta es la razón por la que decidimos llamar a la colección de APIs que hacen posible este patrón Composition API.

Por ejemplo, podemos extraer la lógica de añadir y eliminar un escuchador de eventos del DOM en su propio composable:

event.js
js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // Si quieres, también puedes hacer que
  // soporte cadenas de selección como blanco
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

Y ahora nuestro composable useMouse() puede simplificarse a:

mouse.js
js
import { ref } from 'vue'
import { useEventListener } from './event'

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

  useEventListener(window, 'mousemove', (event) => {
    x.value = event.pageX
    y.value = event.pageY
  })

  return { x, y }
}

TIP

Cada instancia de componente que llame a useMouse() creará sus propias copias del estado x e y para que no interfieran entre sí. Si deseas gestionar el estado compartido entre componentes, lee el capítulo Gestión de Estado.

Ejemplo de Estado Asíncrono

El composable useMouse() no toma ningún argumento, así que veamos otro ejemplo que hace uso de uno. Al realizar la obtención de datos asíncronos, a menudo necesitamos manejar diferentes estados: cargando, éxito y error:

vue
<script setup>
import { ref } from 'vue'

const data = ref(null)
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

<template>
  <div v-if="error">¡Vaya! Se detectó un error: {{ error.message }}</div>
  <div v-else-if="data">
    Datos cargados:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Cargando...</div>
</template>

Sería tedioso tener que repetir este patrón en cada componente que necesita obtener datos. Vamos a extraerlo a un composable:

fetch.js
js
import { ref } from 'vue'

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

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

Ahora en nuestro componente podemos simplemente hacer:

vue
<script setup>
import { useFetch } from './fetch.js'

const { data, error } = useFetch('...')
</script>

Aceptar Estado Reactivo

useFetch() toma una cadena de URL estática como entrada, por lo que realiza la petición solo una vez y luego termina. ¿Qué pasa si queremos que vuelva a realizar la petición cada vez que la URL cambie? Para lograr esto, necesitamos pasar estado reactivo a la función composable, y dejar que el composable cree watchers que realicen acciones usando el estado pasado.

Por ejemplo, useFetch() debería poder aceptar una ref:

js
const url = ref('/initial-url')

const { data, error } = useFetch(url)

// esto debería disparar un re-fetch
url.value = '/new-url'

O aceptar una función getter:

js
// re-fetch cuando props.id cambia
const { data, error } = useFetch(() => `/posts/${props.id}`)

Podemos refactorizar nuestra implementación existente con las APIs watchEffect() y toValue():

fetch.js
js
import { ref, watchEffect, toValue } from 'vue'

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

  const fetchData = () => {
    // restablecer estado antes del fetch...
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

toValue() es una API añadida en la versión 3.3. Está diseñada para normalizar refs o getters en valores. Si el argumento es una ref, devuelve el valor de la ref; si el argumento es una función, la llamará y devolverá su valor de retorno. De lo contrario, devuelve el argumento tal cual. Funciona de manera similar a unref(), pero con un tratamiento especial para las funciones.

Observa que toValue(url) se llama dentro de la función de callback de watchEffect. Esto asegura que cualquier dependencia reactiva accedida durante la normalización de toValue() sea rastreada por el watcher.

Esta versión de useFetch() ahora acepta cadenas de URL estáticas, refs y getters, lo que la hace mucho más flexible. El efecto watch se ejecutará inmediatamente y rastreará cualquier dependencia accedida durante toValue(url). Si no se rastrea ninguna dependencia (por ejemplo, url ya es una cadena), el efecto se ejecuta solo una vez; de lo contrario, se volverá a ejecutar cada vez que una dependencia rastreada cambie.

Aquí tienes la versión actualizada de useFetch(), con un retraso artificial y un error aleatorio con fines de demostración.

Convenciones y Mejores Prácticas

Nombres

Es una convención nombrar las funciones composable con nombres camelCase que comienzan con "use".

Argumentos de Entrada

Un composable puede aceptar argumentos ref o getter incluso si no dependen de ellos para la reactividad. Si estás escribiendo un composable que puede ser usado por otros desarrolladores, es una buena idea manejar el caso de que los argumentos de entrada sean refs o getters en lugar de valores directos. La función de utilidad toValue() será útil para este propósito:

js
import { toValue } from 'vue'

function useFeature(maybeRefOrGetter) {
  // Si maybeRefOrGetter es un ref o un getter,
  // se devolverá su valor normalizado.
  // De lo contrario, se devolverá tal cual.
  const value = toValue(maybeRefOrGetter)
}

Si tu composable crea efectos reactivos cuando la entrada es una ref o un getter, asegúrate de observar explícitamente la ref / getter con watch(), o de llamar a toValue() dentro de un watchEffect() para que se rastree correctamente.

La implementación de useFetch() discutida anteriormente proporciona un ejemplo concreto de un composable que acepta refs, getters y valores planos como argumento de entrada.

Valores de Retorno

Probablemente hayas notado que hemos estado usando exclusivamente ref() en lugar de reactive() en los composables. La convención recomendada es que los composables siempre devuelvan un objeto plano, no reactivo, que contenga múltiples refs. Esto permite que se desestructure en los componentes manteniendo la reactividad:

js
// x y y son refs
const { x, y } = useMouse()

Devolver un objeto reactive desde un composable hará que dichas desestructuraciones pierdan la conexión de reactividad con el estado dentro del composable, mientras que las refs mantendrán esa conexión.

Si prefieres usar el estado devuelto de los composables como propiedades de objeto, puedes envolver el objeto devuelto con reactive() para que las refs se desenvuelvan. Por ejemplo:

js
const mouse = reactive(useMouse())
// mouse.x está vinculado al ref original
console.log(mouse.x)
template
Posición del ratón: {{ mouse.x }}, {{ mouse.y }}

Efectos Secundarios

Está bien realizar efectos secundarios (por ejemplo, añadir escuchadores de eventos del DOM o obtener datos) en los composables, pero presta atención a las siguientes reglas:

  • Si estás trabajando en una aplicación que usa Renderizado en el Lado del Servidor (SSR), asegúrate de realizar los efectos secundarios específicos del DOM en los hooks de ciclo de vida posteriores al montaje, por ejemplo, onMounted(). Estos hooks solo se llaman en el navegador, por lo que puedes estar seguro de que el código dentro de ellos tiene acceso al DOM.

  • Recuerda limpiar los efectos secundarios en onUnmounted(). Por ejemplo, si un composable configura un escuchador de eventos del DOM, debe eliminar ese escuchador en onUnmounted() como hemos visto en el ejemplo de useMouse(). Puede ser una buena idea usar un composable que haga esto automáticamente por ti, como el ejemplo de useEventListener().

Restricciones de Uso

Los composables solo deben llamarse en <script setup> o en el hook setup(). También deben llamarse sincrónicamente en estos contextos. En algunos casos, también puedes llamarlos en hooks de ciclo de vida como onMounted().

Estas restricciones son importantes porque son los contextos en los que Vue puede determinar la instancia de componente activa actual. El acceso a una instancia de componente activa es necesario para que:

  1. Los hooks de ciclo de vida puedan registrarse en ella.

  2. Las propiedades computed y los watchers puedan vincularse a ella, para que puedan eliminarse cuando la instancia se desmonte y evitar fugas de memoria.

TIP

<script setup> es el único lugar donde puedes llamar a los composables después de usar await. El compilador restaura automáticamente el contexto de instancia activa para ti después de la operación asíncrona.

Extrayendo Composables para la Organización del Código

Los composables se pueden extraer no solo para su reutilización, sino también para la organización del código. A medida que la complejidad de tus componentes crece, puedes terminar con componentes demasiado grandes para navegar y razonar sobre ellos. La Composition API te brinda total flexibilidad para organizar el código de tu componente en funciones más pequeñas basadas en preocupaciones lógicas:

vue
<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

Hasta cierto punto, puedes pensar en estos composables extraídos como servicios con ámbito de componente que pueden comunicarse entre sí.

Usando Composables en la Options API

Si estás utilizando la Options API, los composables deben llamarse dentro de setup(), y los enlaces devueltos deben retornarse desde setup() para que se expongan a this y a el template:

js
import { useMouse } from './mouse.js'
import { useFetch } from './fetch.js'

export default {
  setup() {
    const { x, y } = useMouse()
    const { data, error } = useFetch('...')
    return { x, y, data, error }
  },
  mounted() {
    // se puede acceder a las propiedades expuestas de setup() en `this`
    console.log(this.x)
  }
  // ...otras opciones
}

Comparaciones con Otras Técnicas

vs. Mixins

Los usuarios que vienen de Vue 2 pueden estar familiarizados con la opción mixins, que también nos permite extraer la lógica del componente en unidades reutilizables. Hay tres inconvenientes principales en los mixins:

  1. Fuente poco clara de las propiedades: al usar muchos mixins, no queda claro qué propiedad de instancia es inyectada por qué mixin, lo que dificulta rastrear la implementación y comprender el comportamiento del componente. Esta es también la razón por la que recomendamos usar el patrón de refs + desestructuración para los composables: hace que la fuente de la propiedad sea clara en los componentes que los consumen.

  2. Colisiones de nombres: múltiples mixins de diferentes autores pueden registrar las mismas claves de propiedad, causando colisiones de nombres. Con los composables, puedes cambiar el nombre de las variables desestructuradas si hay claves conflictivas de diferentes composables.

  3. Comunicación implícita entre mixins: múltiples mixins que necesitan interactuar entre sí tienen que depender de claves de propiedad compartidas, lo que los hace implícitamente acoplados. Con los composables, los valores devueltos de un composable pueden pasarse a otro como argumentos, al igual que las funciones normales.

Por las razones anteriores, ya no recomendamos usar mixins en Vue 3. La característica se mantiene solo por motivos de migración y familiaridad.

vs. Componentes sin Renderizado

En el capítulo sobre slots de componentes, discutimos el patrón de Componentes sin Renderizado basado en slots con ámbito. Incluso implementamos la misma demostración de seguimiento del ratón utilizando componentes sin renderizado.

La principal ventaja de los composables sobre los componentes sin renderizado es que los composables no incurren en la sobrecarga adicional de una instancia de componente. Cuando se usa en toda una aplicación, la cantidad de instancias de componente adicionales creadas por el patrón de componentes sin renderizado puede convertirse en una sobrecarga de rendimiento notable.

La recomendación es usar composables cuando se reutiliza lógica pura, y usar componentes cuando se reutiliza tanto la lógica como el diseño visual.

vs. React Hooks

Si tienes experiencia con React, habrás notado que esto se parece mucho a los custom React hooks. La Composition API se inspiró en parte en los React hooks, y los composables de Vue son, de hecho, similares a los React hooks en cuanto a las capacidades de composición de lógica. Sin embargo, los composables de Vue se basan en el sistema de reactividad de grano fino de Vue, que es fundamentalmente diferente del modelo de ejecución de los React hooks. Esto se discute con más detalle en las Preguntas Frecuentes de la Composition API.

Lectura Adicional

  • Reactividad en Profundidad: para una comprensión de bajo nivel de cómo funciona el sistema de reactividad de Vue.
  • Gestión del Estado: para patrones de gestión de estado compartido por múltiples componentes.
  • Pruebas de Composables: consejos sobre cómo hacer pruebas unitarias de composables.
  • VueUse: una colección cada vez mayor de composables de Vue. El código fuente también es un excelente recurso de aprendizaje.
Composables