materials

La Composition API de Vue3

Introducción

Vue3 incluye una importante novedad, la Composition API, aunque podemos seguir usando la Options API clásica de Vue2 donde cada elemento (data, computed, methods, …) es una opción del componente.

La forma de trabajar hasta Vue 2 es mediante la Options API donde definíamos un componente mediante una serie de opciones:

export default {
  name: "ComponentName",
  props: { ... },
  data() { return {...} },
  computed: { ... },
  methods: { ... },
  mounted() { ... },
  ...
}

Esto es ideal para pequeñas aplicaciones porque mantiene el código ordenado según su funcionalidad: variables en data, funciones en methods, …. Pero en grandes aplicaciones donde un componente necesita hacer varias cosas (como mostrar datos en una tabla pero que esté paginada y con posibilidad de filtrar, …) el código crece y esta forma de organizarlo se vuelve algo confusa.

Vue3 permite seguir trabajando así pero incorpora una nueva forma de trabajar con nuestros componentes, la Composition API. En ella se define un hook llamado setup() donde escribimos el código que inicializa el componente y devuelve un objeto con las variables y métodos que podrá usar el resto del componente (por ejemplo el template).

<script>
import { defineProps } from "vue";

export default {
  name: "ComponentName",
  props: defineProps({ ... }),     // Props
  setup(props, context) {
    // Init logic, lifecycle hooks, etc...

    return {
      // Data, methods, computed, etc...
    }
  }
}
</script>

La composition API es especialmente útil en aplicaciones grandes ya que va a permitir que nuestros componentes sean mucho más reutilizables. Además nos va a permitir organizar el código por funcionalidades y no por opciones. Por ejemplo si un componente muestra una serie de datos y tiene filtrado de datos y paginación de los mismos en el data() definiré variables para los datos, variables para el filtrado y variables para la paginación. En computed puede que también tenga métodos para las 3 cosas y el methods tendré varios métodos para cada una de las 3 funcionalidades. La composition API me va a permitir que todo el código (data, computed, methods, …) referente a la funcionalidad de mostrar los datos esté junto y lo mismo para las funcionalidades de filtrar y de paginar:

composition api vs options api

Cuándo es recomendable usarla:

Ejemplo básico

Por ejemplo, un componente que muestra un contador y un botón para incrementarlo, con la Options API sería:

<template>
  <div>
    <h1>{ { title }}</h1>
    <p>El valor del contador es: { { count }}<p>
    <button @click="increment">Incrementar</button>
</template>

<script>
export default {
  // Properties returned from data() becomes reactive state
  // and will be exposed on `this`.
  props: ['title'],
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    console.log(`The initial count is ${this.count}.`)
  }
}
</script>

Este ejemplo con la Composition API quedaría:

<script>
import { ref, onMounted } from "vue";

export default {
  name: "ComponentName",
  props: ['title'],
  
  setup(props, context) {
    const count = ref(0)

    function increment() {
      count.value++
    }

    onMounted(() => {
      console.log(`${props.title}: the initial count is ${count.value}.`)
    })

    return {
      count,
      increment,
    }
  }
}
</script>

<template>
  <div>
    <h1>{ { title }}</h1>
    <p>El valor del contador es: { { count }}<p>
    <button @click="increment">Incrementar</button>
</template>

Fijaos que para hacer reactiva una variable hemos de declararla con ref y su valor lo obtenemos dentro de la propiedad .value, aunque en el <template> no es necesario poner el .value. En el caso de objetos (incluidos arrays) se hacen reactivos con reactive como veremos al hablar de la reactividad.

Las funciones podemos ponerlas como arrow functions:

    const increment = () => {
      count.value++
    }

setup

Lo primero que hace un componente que usa esta API es ejecutar su método setup, antes de evaluar ninguna otra característica (data, computed, hooks, …). Por tanto este método no tiene acceso a this como el resto. Para que pueda acceder a datos que pueda necesitar recibe 2 parámetros:

El hook setup() se encarga de:

<script setup>

Además de la sintaxis que hemos visto arriba existe una forma ‘reducida’ de escribir la parte de <script> que es:

<script setup>
import { ref, defineProps, onMounted } from "vue";

const props = defineProps(['title'])
const count = ref(0)

const increment = () => {
  count.value++
}

onMounted(() => {
  console.log(`${props.title}: the initial count is ${count.value}.`)
})
</script>

<template>
  <div>
    <h1>{ { title }}</h1>
    <p>El valor del contador es: { { count }}<p>
    <button @click="increment">Incrementar</button>
</template>

En este caso no es necesario exportar nada (por defecto se exportan las variables y funciones definidas).

Esta es la sintaxis recomendada cuando usamos SFC por simplicidad y rendimiento tal y como se indica en la documentación de Vue3.

Reactividad en Vue3

En la composition API de Vue3 sólo las variables recogidas en props son reactivas. Cualquier otra declarada en el setup que queramos que lo sea debemos declararla con ref si es un tipo primitivo o reactive si es un objeto.

La función ref envuelve la variable en un Proxy reactivo. El valor de la variable estará en su propiedad .value, aunque desde el template podemos usarla directamente como hemos visto en el código anterior.

En el caso de variables de tipos no primitivos (objetos, arrays, …) se declaran con reactive pero en este caso no es necesario usar la propiedad .value (es lo mismo que hace el método data() en la options API):

<script setup>
import { reactive, defineProps, onMounted } from "vue";

const props = defineProps(['title'])
const counter = reactive({ count: 0})

const increment = () => {
  counter.count++
}

onMounted(() => {
  console.log(`${props.title}: the initial count is ${counter.count}.`)
})
</script>

<template>
  <div>
    <h1>{ { title }}</h1>
    <p>El valor del contador es: { { counter.count }}<p>
    <button @click="increment">Incrementar</button>
</template>

Sin embargo si cambiamos la referencia del objeto (por ejemplo si lo desestructuramos) pierde su reactividad.

import { reactive } from "vue";

const counter = reactive({ count: 0})
let { count } = counter   // count no es reactivo
count++   // no afecta a counter.count

Para hacerlo reactivo deberíamos usar el método toRef() o toRefs():

import { reactive } from "vue";

const counter = reactive({ count: 0})
let { count } = toRefs(counter)   // count SÍ es reactivo

O bien, si queremos trabajar con las propiedades de un objeto podemos declararlas con ref:

import { ref } from "vue";

const counter = { count: ref(0) }
let { count } = counter   // count SÍ es reactivo

También hay métodos para ver si una variable es reactiva:

Podéis ver esto con más detalle en:

Configuraciones básicas

Props

Para tener acceso a las props hay que hacerlas accesibles con defineProps:

<script setup>
import { defineProps } from "vue";

defineProps(['title'])
</script>

<template>
  <div>
    <h1>{ { title }}</h1>
  </div>
</template>

Si necesitamos acceder a ellas desde el código las asignamos a una variable:

<script setup>
import { onMounted, defineProps } from "vue";

const props = defineProps(['title'])

onMounted(() => {
  console.log(`El parámetro pasado en 'title' es ${props.title}`)
})
</script>

<template>
  <div>
    <h1>{ { title }}</h1>
  </div>
</template>

Components

No necesitamos registrarlos, basta con importarlos y ya se pueden usar:

<script setup>
import ErrorMessages from "./components/ErrorMessages.vue";
import AppNav from "./components/AppNav.vue";
</script>

<template>
  <div>
    <app-nav></app-nav>
    <div class="container">
      <error-messages></error-messages>
    </div>
  </div>
</template>

Computed

El uso de computed cambia ya que ahora es una función en lugar de un objeto.

# Options API
data(): {
  return {
    productPrice: 100
  }
},
computed: {
  offerPrice() {
    return this.productPrice * 50%
  },
  originalPrice() {
    return this.productPrice
  },
}
// Composition API
import { ref, computed } from "vue";

const productPrice = ref(100)

const offerPrice = computed(() => productPrice.value * 50%)
const originalPrice = computed(() => productPrice.value)
...

NOTA: Todas las variables definidas como computed son automáticamente reactivas.

hooks

Se les antepone on (ej, onMounted). Ya no son necesarios ni beforeCreated ni created que son sustituidas por el setup.

Podéis ver esto con más detalle en la documentación de Vue.

router

Para acceder al router y a la variable route en composition API tenemos que importarlas de vue-router e instanciarlas, ya que no tenemos acceso a this:

import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

watchEffect y watch

watch funciona como en Vue2:

# Vue 3
import { ref, watch } from "vue";
setup(props) {
  const productPrice = ref(props.price);
  watch(productPrice, (current, prev) => {
    console.log('productPrice current: ' + current + ', prev: ' + prev)
  })
  ...

watchEffect es una función que se ejecuta inmediatamente y cada vez que cambie alguna de sus dependencias reactivas:

import { ref, watchEffect } from "vue";
setup(props) {
  const productPrice = ref(props.price);
  watchEffect(() => {
    console.log('productPrice current: ' + productPrice.value)
  })
  ...

Podemos obtener más información sobre cuándo usar un u otro método en Escuela VUE.

Pinia

Los ficheros de store no cambian pero sí la forma de usarlos en el componente. Allí se importa el store y cada variable, getter o action que queramos usar en el componente:

<script setup>
import { useCounterStore } from '../stores/counterStore';
import { computed } from 'vue';

   // store
   const counterStore = useCounterStore();

   //state & getters
   const count = computed(() => counterStore.count);  // state
   const lastOperation = computed(() => counterStore.lastOperation);  // getter

   //actions
   const increment = () => counterStore.increment();
   const decrement = () => counterStore.decrement();
</script>

<template>
    <div>
        <p>Counter: { { count }}</p>
        <p>Last: { { lastOperation }}</p>
        <button @click="increment()">Add</button>
        <button @click="decrement()">Subtract</button>
    </div>
</template>

Reusabilidad: composables

La principal razón de ser de la composition API es que permite usar funciones composables, que son funciones donde podemos poner código con estado (es decir, que usa variables reactivas). El nombre de las funciones composables por convenio comienza por use y se usan para encapsular código que podrá usar cualquier componente.

Por ejemplo podemos hacer una composable que nos proporcione la posición actual del ratón:

// mouse.js

import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  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))

  return { x, y }
}

La función useMouse proporciona a quien la importe 2 variables reactivas (x e y) donde se encuentra la posición actual del ratón, actualizada por la función update.

En cualquier componente donde necesitemos conocer la posición del ratón sólo necesitamos importar esta función:

<script setup>
  import { useMouse } from './useMouse';
  const { x, y } = useMouse();
</script>

<template>
  X: { { x }} Y: { { y }}
</template>

Siempre que pongamos un escuchador en una composable (como hemos hecho en el onMounted) debemos quitarlo cuando ya no se utilice (en el unMounted).

Valores devueltos

Como se ve la composable devuelve un objeto formado por variables reactivas (refs) en lugar de un objeto reactivo. Se hace así por convención, lo que permite desestructurar las variables en el componente que las vaya a usar sin perder su reactividad (al desestructurar un reactive deja de serlo).

Si lo hubiéramos hecho con un reactive NO funcionaría:

// mouse.js MAL

import { reactive, onMounted, onUnmounted } from 'vue'

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

  ...
  return reactive({ x, y })
}

porque entonces al hacer en el componente

  const { x, y } = useMouse();

las variables x e y dejarían de ser reactivas.

Podría hacerse no desestructurando el objeto, pero se prefiere así por claridad, para tener claras qué variables nos proporciona la función:

<script setup>
  import { useMouse } from './useMouse';
  const position = useMouse();
</script>

<template>
  X: { { position.x }} Y: { { position.y }}
</template>

Esto sí funcionaría pero se recomienda la otra forma: una composable devuelve un array de variables reactivas que se importan (desestructurando el objeto) en el componente que las vaya a usar.

Paso de parámetros

Podemos pasar parámetros a las funciones composables en el momento de usarlas y dichos parámetros los recibirá directamente la composable como cualquier otra función.

Por ejemplo podemos crear useFetch a la que le pasamos una url y hace un fetch para hacer la llamada a esa url y devolver los datos o el error devueltos por el servidor. El componente que quiera usarla haría:

<script setup>
  import { useFetch } from './useFetch';
  const { data, error } = useFetch('https://jsonplaceholder.typicode.com/users/3')
</script>

<template>
  <div v-if="error"></div>
  <div v-else>
    // Aquí mostramos los datos recibidos en la variable 'data'
  </div>
</template>

Y nuestra función haría:

// useFetch.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 }
}

Si el parámetro recibido es reactivo podemos hacer que la función se ejecute cada vez que cambie observándolo con watch o watchEffect:

// fetch.js
import { ref, watch } from 'vue'

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

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

  watch(url, () => doFetch())
  
  return { data, error }
}

Si nuestras composables pueden recibir parámetros reactivos siempre es una buena práctica que puedan recibir también parámetros primitivos (en el caso anterior daría un error al hacer fetch(url.value) porque url es un string). La forma más correcta de hacerlo sería:

// fetch.js
import { ref, isRef, unref, watchEffect } from 'vue'

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

  function doFetch() {
    // reset state before fetching..
    data.value = null
    error.value = null
    // unref() unwraps potential refs
    fetch(unref(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  if (isRef(url)) {
    // setup reactive re-fetch if input URL is a ref
    watchEffect(doFetch)
  } else {
    // otherwise, just fetch once
    // and avoid the overhead of a watcher
    doFetch()
  }

  return { data, error }
}

En este caso se ha hecho una función que va a funcionar tanto si se le pasa un url estática como si se le pasa una reactiva. Lo que ha cambiado es:

Organizar el código con composables

Además de para que el código sea fácilmente reutilizable, las composables se usan para sacar código de un componente cuando este es demasiado grande o se encarga de varias funcionalidades. Una vez creadas las funciones se usan en el componente:

<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>

Si tenemos que usar una función composable en un componente escrito en modo Options API simplemente añadimos el hook setup y allí la llamamos:

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() {
    // setup() exposed properties can be accessed on `this`
    console.log(this.x)
  }
  // ...other options
}

Algunos enlaces útiles:

Podemos encontrar infinidad de composables que podemos usar en nuestro código en la página VueUse.