DEV Community

loading...

composition api

ajcwebdev profile image anthonyCampolo ・6 min read

Imagine we have a view to show a list of repositories of a certain user in our app. We want to apply search and filter capabilities.

Our component could look like this:

export default {
  components: {
    RepositoriesFilters,
    RepositoriesSortBy,
    RepositoriesList
  },

  props: {
    user: {
      type: String,
      required: true
    }
  },

  data () {
    return {
      repositories: [],
      filters: { ... },
      searchQuery: ''
    }
  },

  computed: {
    filteredRepositories () { ... },
    repositoriesMatchingSearchQuery () { ... },
  },

  watch: {
    user: 'getUserRepositories'
  },

  methods: {
    getUserRepositories () {
      // using `this.user` to fetch user repositories
    },
    updateFilters () { ... },
  },

  mounted () {
    this.getUserRepositories()
  }
}
Enter fullscreen mode Exit fullscreen mode

This component has several responsibilities:

  • Get repository from external API for that username and refresh it when user changes
  • Search for repositories using searchQuery string
  • Filter repositories using filters object

Organizing logics with component's options (data, computed, methods, watch) works in most cases. However, when our components get bigger, the list of logical concerns also grows leading to components that are hard to read and understand. It would be much nicer if we could collocate code related to the same logical concern.

setup Component Option

The new setup component option is executed before the component is created, once the props are resolved, and serves as the entry point for composition API's.

The setup option should be a function that accepts props and context. Everything that we return from setup will be exposed to the rest of our component (computed properties, methods, lifecycle hooks) as well as to the component's template.

Let’s add setup to our component. Anything returned will be available for the rest of the component.

setup(props) {
  console.log(props) // { user: '' }

  return {}
Enter fullscreen mode Exit fullscreen mode

To extract the first logical concern we will start with:

  • The list of repositories
  • The function to update the list of repositories
  • Returning both the list and the function so they are accessible by other component options
import { fetchUserRepositories } from '@/api/repositories'

// inside our component
setup (props) {
  let repositories = []
  const getUserRepositories = async () => {
    repositories = await fetchUserRepositories(props.user)
  }

  return {
    repositories,
    getUserRepositories
  }
}
Enter fullscreen mode Exit fullscreen mode

Functions returned behave the same as methods. Next we need to make our repositories variable reactive.

Reactive Variables with ref

We can make any variable reactive anywhere with a ref function.

import { ref } from 'vue'

const counter = ref(0)
Enter fullscreen mode Exit fullscreen mode

ref takes the argument and returns it wrapped within an object with a value property. The value property can be used to access or mutate the value of the reactive variable.

import { ref } from 'vue'

const counter = ref(0)

console.log(counter) // { value: 0 }
console.log(counter.value) // 0

counter.value++
console.log(counter.value) // 1
Enter fullscreen mode Exit fullscreen mode

Wrapping values inside an object keeps the behavior unified across different data types in JavaScript. In JavaScript primitive types like Number or String are passed by value, not by reference.

Having a wrapper object around any value allows us to safely pass it across our whole app without worrying about losing its reactivity somewhere along the way. Now we'll create a reactive repositories variable.

import { fetchUserRepositories } from '@/api/repositories'
import { ref } from 'vue'

// inside our component
setup (props) {
  const repositories = ref([])
  const getUserRepositories = async () => {
    repositories.value = await fetchUserRepositories(props.user)
  }

  return {
    repositories,
    getUserRepositories
  }
}
Enter fullscreen mode Exit fullscreen mode

Whenever we call getUserRepositories, repositories will be mutated and the view will be updated to reflect the change.

import { fetchUserRepositories } from '@/api/repositories'
import { ref } from 'vue'

export default {
  components: {
    RepositoriesFilters,
    RepositoriesSortBy,
    RepositoriesList
  },

  props: {
    user: {
      type: String,
      required: true
    }
  },

  setup (props) {
    const repositories = ref([])
    const getUserRepositories = async () => {
      repositories.value = await fetchUserRepositories(props.user)
    }

    return {
      repositories,
      getUserRepositories
    }
  },

  data () {
    return {
      filters: { ... }, // 3
      searchQuery: '' // 2
    }
  },

  computed: {
    filteredRepositories () { ... }, // 3
    repositoriesMatchingSearchQuery () { ... }, // 2
  },

  watch: {
    user: 'getUserRepositories' // 1
  },

  methods: {
    updateFilters () { ... }, // 3
  },

  mounted () {
    this.getUserRepositories() // 1
  }
}
Enter fullscreen mode Exit fullscreen mode

We still have to:

  • Call getUserRepositories in the mounted hook
  • Set up a watcher to call getUserRepositories when the user prop changes

Lifecycle Hook Registration Inside setup

We need a way to register lifecycle hooks inside setup. This is possible with several new functions exported from Vue. Lifecycle hooks on composition API have the same name as for Options API but are prefixed with on: i.e. mounted would look like onMounted.

These functions accept a callback that will be executed when the hook is called by the component.

import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted } from 'vue'

// in our component
setup (props) {
  const repositories = ref([])
  const getUserRepositories = async () => {
    repositories.value = await fetchUserRepositories(props.user)
  }

  onMounted(getUserRepositories)

  return {
    repositories,
    getUserRepositories
  }
}
Enter fullscreen mode Exit fullscreen mode

To react to the changes made to the user prop we will use the standalone watch function.

Reacting to Changes with watch

Just like how we set up a watcher on the user property inside our component using the watch option, we can do the same using the watch function imported from Vue. It accepts 3 arguments:

  • A Reactive Reference or getter function that we want to watch
  • A callback
  • Optional configuration options
import { ref, watch } from 'vue'

const counter = ref(0)
watch(counter, (newValue, oldValue) => {
  console.log('The new counter value is: ' + counter.value)
})
Enter fullscreen mode Exit fullscreen mode

Whenever counter is modified, for example counter.value = 5, the watch will trigger and execute the callback (second argument) which in this case will log 'The new counter value is: 5' into our console.

Options equivalent:

export default {
  data() {
    return {
      counter: 0
    }
  },
  watch: {
    counter(newValue, oldValue) {
      console.log('The new counter value is: ' + this.counter)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Applied to our example:

import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted, watch, toRefs } from 'vue'

// in our component
setup (props) {
  // using `toRefs` to create a Reactive Reference to the `user` property of props
  const { user } = toRefs(props)

  const repositories = ref([])
  const getUserRepositories = async () => {
    // update `props.user` to `user.value` to access the Reference value
    repositories.value = await fetchUserRepositories(user.value)
  }

  onMounted(getUserRepositories)

  // set a watcher on the Reactive Reference to user prop
  watch(user, getUserRepositories)

  return {
    repositories,
    getUserRepositories
  }
}
Enter fullscreen mode Exit fullscreen mode

You probably have noticed the use of toRefs at the top of our setup. This is to ensure our watcher will react to changes made to the user prop.

With those changes in place, we've just moved the whole first logical concern into a single place. We can now do the same with the second concern – filtering based on searchQuery, this time with a computed property.

Standalone computed properties

Similar to ref and watch, computed properties can also be created outside of a Vue component with the computed function imported from Vue.

import { ref, computed } from 'vue'

const counter = ref(0)
const twiceTheCounter = computed(() => counter.value * 2)

counter.value++
console.log(counter.value) // 1
console.log(twiceTheCounter.value) // 2
Enter fullscreen mode Exit fullscreen mode

The computed function returns a read-only Reactive Reference to the output of the getter-like callback passed as the first argument to computed. To access the value of the newly-created computed variable, we need to use the .value property just like with ref.

Let’s move our search functionality into setup:

import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted, watch, toRefs, computed } from 'vue'

// in our component
setup (props) {
  // using `toRefs` to create a Reactive Reference to the `user` property of props
  const { user } = toRefs(props)

  const repositories = ref([])
  const getUserRepositories = async () => {
    // update `props.user` to `user.value` to access the Reference value
    repositories.value = await fetchUserRepositories(user.value)
  }

  onMounted(getUserRepositories)

  // set a watcher on the Reactive Reference to user prop
  watch(user, getUserRepositories)

  const searchQuery = ref('')
  const repositoriesMatchingSearchQuery = computed(() => {
    return repositories.value.filter(
      repository => repository.name.includes(searchQuery.value)
    )
  })

  return {
    repositories,
    getUserRepositories,
    searchQuery,
    repositoriesMatchingSearchQuery
  }
}
Enter fullscreen mode Exit fullscreen mode

Before moving on with the other responsibilities, we will first extract the above code into a standalone composition function starting with creating useUserRepositories:

// src/composables/useUserRepositories.js

import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted, watch } from 'vue'

export default function useUserRepositories(user) {
  const repositories = ref([])
  const getUserRepositories = async () => {
    repositories.value = await fetchUserRepositories(user.value)
  }

  onMounted(getUserRepositories)
  watch(user, getUserRepositories)

  return {
    repositories,
    getUserRepositories
  }
}
Enter fullscreen mode Exit fullscreen mode

And then the searching functionality:

// src/composables/useRepositoryNameSearch.js

import { ref, computed } from 'vue'

export default function useRepositoryNameSearch(repositories) {
  const searchQuery = ref('')
  const repositoriesMatchingSearchQuery = computed(() => {
    return repositories.value.filter(repository => {
      return repository.name.includes(searchQuery.value)
    })
  })

  return {
    searchQuery,
    repositoriesMatchingSearchQuery
  }
}
Enter fullscreen mode Exit fullscreen mode

Now having those two functionalities in separate files, we can start using them in our component. Here’s how this can be done:

// src/components/UserRepositories.vue
import useUserRepositories from '@/composables/useUserRepositories'
import useRepositoryNameSearch from '@/composables/useRepositoryNameSearch'
import { toRefs } from 'vue'

export default {
  components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
  props: {
    user: {
      type: String,
      required: true
    }
  },

  setup (props) {
    const { user } = toRefs(props)

    const { repositories, getUserRepositories } = useUserRepositories(user)

    const {
      searchQuery,
      repositoriesMatchingSearchQuery
    } = useRepositoryNameSearch(repositories)

    return {
      // Since we don’t really care about the unfiltered repositories
      // we can expose the filtered results under the `repositories` name
      repositories: repositoriesMatchingSearchQuery,
      getUserRepositories,
      searchQuery,
    }
  },

  data () {
    return {
      filters: { ... }, // 3
    }
  },

  computed: {
    filteredRepositories () { ... }, // 3
  },

  methods: {
    updateFilters () { ... }, // 3
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point you probably already know the drill, so let’s skip to the end and migrate the leftover filtering functionality. We don’t really need to get into the implementation details as it’s not the point of this guide.

// src/components/UserRepositories.vue
import { toRefs } from 'vue'
import useUserRepositories from '@/composables/useUserRepositories'
import useRepositoryNameSearch from '@/composables/useRepositoryNameSearch'
import useRepositoryFilters from '@/composables/useRepositoryFilters'

export default {
  components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
  props: {
    user: {
      type: String,
      required: true
    }
  },

  setup(props) {
    const { user } = toRefs(props)

    const { repositories, getUserRepositories } = useUserRepositories(user)

    const {
      searchQuery,
      repositoriesMatchingSearchQuery
    } = useRepositoryNameSearch(repositories)

    const {
      filters,
      updateFilters,
      filteredRepositories
    } = useRepositoryFilters(repositoriesMatchingSearchQuery)

    return {
      // Since we don’t really care about the unfiltered repositories
      // we can expose the end results under the `repositories` name
      repositories: filteredRepositories,
      getUserRepositories,
      searchQuery,
      filters,
      updateFilters
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Discussion

pic
Editor guide