Chapter 03 - Unlock Vue.js Superpowers: Master Composables for Modular, Efficient Code

Vue.js Composition API's composables enable reusable logic across components. They encapsulate functionality, improve code modularity, and enhance maintainability. Composables are versatile, suitable for various tasks like form handling, API calls, and state management.

Chapter 03 - Unlock Vue.js Superpowers: Master Composables for Modular, Efficient Code

Vue.js has revolutionized the way we build user interfaces, and with the introduction of the Composition API, developers now have even more powerful tools at their disposal. One of the coolest features that came with this API is the ability to create reusable composables. These nifty little functions allow us to share logic between components, making our code more modular and easier to maintain.

So, what exactly are composables? Think of them as your own personal Swiss Army knife for Vue.js development. They’re functions that encapsulate and reuse stateful logic across multiple components. This means you can extract common functionality, package it up nicely, and use it wherever you need it. It’s like having a bunch of mini-superheroes ready to swoop in and save the day (or at least save you from writing repetitive code).

Let’s dive into a real-world example to see how this works in practice. Imagine we’re building a blog platform, and we want to implement a feature that tracks how long a user spends reading an article. We could write this logic directly in each component that displays an article, but that would lead to a lot of duplicate code. Instead, let’s create a reusable composable for this functionality.

Here’s what our useReadingTime composable might look like:

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

export function useReadingTime() {
  const startTime = ref(null)
  const endTime = ref(null)
  const readingTime = ref(0)

  const startReading = () => {
    startTime.value = new Date()
  }

  const stopReading = () => {
    endTime.value = new Date()
    readingTime.value = (endTime.value - startTime.value) / 1000
  }

  onMounted(() => {
    startReading()
  })

  onUnmounted(() => {
    stopReading()
  })

  return {
    readingTime,
    startReading,
    stopReading
  }
}

This composable does a few things:

  1. It creates reactive references for startTime, endTime, and readingTime.
  2. It defines functions to start and stop the reading timer.
  3. It automatically starts the timer when the component is mounted and stops it when unmounted.
  4. It returns the reading time and the start/stop functions, making them available to any component that uses this composable.

Now, let’s see how we can use this composable in a component:

<template>
  <div>
    <h1>{{ article.title }}</h1>
    <p>{{ article.content }}</p>
    <p>You've been reading for {{ readingTime }} seconds</p>
  </div>
</template>

<script>
import { useReadingTime } from './composables/useReadingTime'

export default {
  setup() {
    const { readingTime } = useReadingTime()

    return {
      readingTime
    }
  }
}
</script>

Just like that, we’ve added reading time tracking to our component with minimal effort. And the best part? We can use this same composable in any other component that displays an article, without duplicating any code.

But wait, there’s more! Composables aren’t just for simple tasks like tracking time. They can handle more complex operations too. Let’s say we want to create a composable that manages API calls for our blog platform. We could create a useArticles composable that handles fetching, creating, updating, and deleting articles.

Here’s what that might look like:

import { ref } from 'vue'
import axios from 'axios'

export function useArticles() {
  const articles = ref([])
  const loading = ref(false)
  const error = ref(null)

  const fetchArticles = async () => {
    loading.value = true
    try {
      const response = await axios.get('/api/articles')
      articles.value = response.data
    } catch (err) {
      error.value = 'Failed to fetch articles'
    } finally {
      loading.value = false
    }
  }

  const createArticle = async (articleData) => {
    loading.value = true
    try {
      const response = await axios.post('/api/articles', articleData)
      articles.value.push(response.data)
    } catch (err) {
      error.value = 'Failed to create article'
    } finally {
      loading.value = false
    }
  }

  const updateArticle = async (id, articleData) => {
    loading.value = true
    try {
      const response = await axios.put(`/api/articles/${id}`, articleData)
      const index = articles.value.findIndex(article => article.id === id)
      if (index !== -1) {
        articles.value[index] = response.data
      }
    } catch (err) {
      error.value = 'Failed to update article'
    } finally {
      loading.value = false
    }
  }

  const deleteArticle = async (id) => {
    loading.value = true
    try {
      await axios.delete(`/api/articles/${id}`)
      articles.value = articles.value.filter(article => article.id !== id)
    } catch (err) {
      error.value = 'Failed to delete article'
    } finally {
      loading.value = false
    }
  }

  return {
    articles,
    loading,
    error,
    fetchArticles,
    createArticle,
    updateArticle,
    deleteArticle
  }
}

This composable manages the state of our articles, including loading and error states, and provides methods for all CRUD operations. We can now use this composable in any component that needs to interact with articles:

<template>
  <div>
    <h1>My Blog</h1>
    <button @click="fetchArticles">Refresh Articles</button>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="article in articles" :key="article.id">
        {{ article.title }}
        <button @click="deleteArticle(article.id)">Delete</button>
      </li>
    </ul>
  </div>
</template>

<script>
import { useArticles } from './composables/useArticles'

export default {
  setup() {
    const { articles, loading, error, fetchArticles, deleteArticle } = useArticles()

    fetchArticles()

    return {
      articles,
      loading,
      error,
      fetchArticles,
      deleteArticle
    }
  }
}
</script>

The beauty of this approach is that all the complex logic for managing articles is neatly encapsulated in the useArticles composable. Our component remains clean and focused on presentation, while still having full access to all the article management functionality.

But composables aren’t just about separating concerns and reusing logic. They also make our code more testable. Since composables are just functions, we can easily unit test them in isolation from our components. This leads to more robust and reliable code.

For example, we could write tests for our useArticles composable like this:

import { useArticles } from './useArticles'
import axios from 'axios'

jest.mock('axios')

describe('useArticles', () => {
  it('fetches articles successfully', async () => {
    const mockArticles = [{ id: 1, title: 'Test Article' }]
    axios.get.mockResolvedValue({ data: mockArticles })

    const { articles, loading, error, fetchArticles } = useArticles()

    expect(articles.value).toEqual([])
    expect(loading.value).toBe(false)
    expect(error.value).toBeNull()

    await fetchArticles()

    expect(articles.value).toEqual(mockArticles)
    expect(loading.value).toBe(false)
    expect(error.value).toBeNull()
  })

  // More tests...
})

These tests ensure that our composable behaves correctly under different scenarios, giving us confidence in our code.

Now, you might be wondering, “This all sounds great, but when should I use composables?” Great question! Composables are particularly useful when you have pieces of logic that you find yourself repeating across multiple components. Some common use cases include:

  1. Form handling and validation
  2. Authentication and user management
  3. Data fetching and state management
  4. Websocket connections
  5. Browser APIs (like geolocation or local storage)

For instance, let’s say we’re building a form for creating new articles. We could create a useForm composable to handle form state and validation:

import { ref, computed } from 'vue'

export function useForm(initialValues = {}) {
  const values = ref({ ...initialValues })
  const errors = ref({})

  const validate = () => {
    errors.value = {}
    if (!values.value.title) {
      errors.value.title = 'Title is required'
    }
    if (!values.value.content) {
      errors.value.content = 'Content is required'
    }
    return Object.keys(errors.value).length === 0
  }

  const isValid = computed(() => Object.keys(errors.value).length === 0)

  const resetForm = () => {
    values.value = { ...initialValues }
    errors.value = {}
  }

  return {
    values,
    errors,
    validate,
    isValid,
    resetForm
  }
}

We can then use this composable in our article creation form:

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <label for="title">Title:</label>
      <input id="title" v-model="values.title" />
      <span v-if="errors.title" class="error">{{ errors.title }}</span>
    </div>
    <div>
      <label for="content">Content:</label>
      <textarea id="content" v-model="values.content"></textarea>
      <span v-if="errors.content" class="error">{{ errors.content }}</span>
    </div>
    <button type="submit" :disabled="!isValid">Create Article</button>
  </form>
</template>

<script>
import { useForm } from './composables/useForm'
import { useArticles } from './composables/useArticles'

export default {
  setup() {
    const { values, errors, validate, isValid } = useForm({ title: '', content: '' })
    const { createArticle } = useArticles()

    const handleSubmit = async () => {
      if (validate()) {
        await createArticle(values.value)
        // Handle successful creation (e.g., show a success message, redirect)
      }
    }

    return {
      values,
      errors,
      isValid,
      handleSubmit
    }
  }
}
</script>

By using composables, we’ve separated our form handling logic from our component logic, making both easier to understand and maintain. We can now reuse this form handling logic in any other forms we create in our application.

One of the great things about composables is how flexible they are. You can combine multiple composables to create more complex functionality. For example, we could combine our useForm and useArticles composables to create a useArticleForm composable:

import { useForm } from './useForm'
import { useArticles } from './useArticles'

export function useArticleForm() {
  const { values, errors, validate, isValid, resetForm } = useForm({ title: '', content: '' })
  const { createArticle, updateArticle } = useArticles()

  const saveArticle = async (id = null) => {
    if (validate()) {
      if (id) {
        await updateArticle(id, values.value)
      } else {
        await createArticle(values.value)
      }
      resetForm()
      return true
    }
    return false
  }

  return {
    values,
    errors,
    isValid,
    saveArticle
  }
}