Diving Into Vue 3 - Reusability with Composables

Share

  1. Blog
  2. 2022
  3. 02
  4. Diving Into Vue 3 - Reusability with Composables

Introduction

This is the fifth and final post of my 'Diving Into Vue 3' series. Today I will combine what I have learned so far with a demonstration of how to use the Composition API to take advantage of its biggest strength: writing reusable code. This post will:

  • review everything I've learned so far by walking through how I build an example component, focusing on challenges of working with the DOM and using lifecycle methods.
  • introduce how to use a template ref to keep track of an element in the DOM.
  • demonstrate how to refactor the project to use composition functions (i.e. composables).

Don't forget there are four previous posts in this series that might be useful to you:

If you don't need the walk-through for building the example project, feel free to jump to the section on reusability, where I show how to refactor the project to use composables.

Example Component

I am going to build a single-file component that has a mast with an image on the left and text on the right. The problem I need to address is that I want to change the size of the text based on the image being resized.

Here's the demo:

To achieve this, I will:

  • listen for resizing of the window with an event listener.
  • track the image size.
  • update the text size if the image gets to a certain size.

The repo to go along with this example can be found here. There are several branches to show the progression of how the project gets refactored.

Vue 2

I won't go over how I built the project in Vue 2, but if it helps, the completed project in Vue 2 can be viewed here.

Resizing the window will show how the text size changes as the width of the image changes.

Vue 3

Here's how to build the component in Vue 3. The html in the template is exactly the same as the Vue 2 project:

<template>
  <div class="mast">
    <div class="container">
      <div class="image-container">
        <img ref="imageRef" src="../assets/meatball.jpeg" />
      </div>
      <div ref="textRef" class="text-container">
        <p>
          Meatball, 9. Barks at Amazon guy. Likes sharing your apple slices.
          Wants you to grab the toy but won't let you have it.
        </p>
      </div>
    </div>
  </div>
</template>

In the script section, I'll need to add the setup function, and then I will define the variables for the data I'll be tracking. Since elements in the DOM will depend on each other to either trigger a change or react to a change, I will need to make them reactive using ref so everything stays in sync. Here's how I do that:

<script>
import { ref } from "vue";
export default {
  name: "Mast",
  setup() {
    let imageWidth = ref(0);

    //template refs
    let imageRef = ref(null);
    let textRef = ref(null);

    return { imageRef, textRef };
  },
};
</script>

The important data to keep track of is the imageWidth because that value is what I will use to determine if the text size should change.

The imageWidth value has to come from the image element in the DOM. It will be based on the actual size of the image at a point in time, so I will need to connect to the actual DOM element using a template ref.

Template Refs

I think of template refs as the Vue way of using Javascript to hook into a DOM element, such as the method document.getElementById() or document.querySelector().

In Vue 2, the way to do that is to add ref="nameOfRef" as an attribute on the element that I am targeting, then in the script, I could perform some action on it using this.$refs.nameOfRef.

In Vue 3, template refs are now part of the reactive API. If I want to set up a template ref, I still need to add ref="nameOfRef" as an attribute on the element that I want to hook into.

<img ref="imageRef" src="../assets/meatball.jpeg" />

The difference now is that in the script, I need to define the template ref as a reactive reference variable wrapped in ref. And I MUST return it in the return object of the setup function so that it connects to that DOM element in the template. If I don't, it won't work.

setup() {
    //template refs
    let imageRef = ref(null);
    let textRef = ref(null);

    return { imageRef, textRef };
  },

Also, I need to be aware that I won't be able to actually access the ref to do something with it until the component has mounted - which brings me to the next topic.

Lifecycle Hooks

Now that I have the data set up I can add the logic to listen for the resize event.

I want to track the size of the image, which will change depending on if the window is resized. Since I'm dealing with a visual element, I need to consider timing of when that element will appear in the browser. It won't appear until the component has mounted.

The hooks that I'll need for setting up the event listener (and destroying it) are onMounted and onUnmounted, which are the equivalent to mounted and unmounted in Vue 2.

In onMounted, I have access to the template ref, so I will first set the initial value of the imageWidth based on the width of the actual image, which I pull from the template ref. I will also put a listener on the window to track the resizing event so that as the window is resized, the resizeHandler function runs.

Everything currently resides in the setup function for now, but will be refactored later and moved into composables:

// inside setup function:

onMounted(() => {
  //set initial value
  imageWidth.value = imageRef.value.offsetWidth

  //add listener to track resize
  window.addEventListener('resize', resizeHandler)
})

The resizeHandler sets the imageWidth value to the imageRef's width. I have to remember that with refs in the script, I have to unwrap the value using .value:

// inside setup function:

function resizeHandler() {
  //tracking of width changes
  imageWidth.value = imageRef.value.offsetWidth
}

Since I'm listening for the resize event starting when the component mounts, I need to be sure to destroy the listener when the component unmounts:

// inside setup function:

onUnmounted(() => {
  //remove listener
  window.removeEventListener('resize', resizeHandler)
})

watch

I now have the data set up so that the imageWidth updates in-sync with the imageRef's width as the event listener fires the resizeHandler function.

The last thing I need to do is make something happen as a side effect of the imageWidth increasing or decreasing. Vue offers watch and watchEffect as part of the API for watching a reactive property and causing a side effect to occur based on changes to the property.

In this case, I will use watch because I only need to track the imageWidth value since a change to imageWidth is what I'm using to cause the text size to change.

// inside setup function:

watch(imageWidth, () => {
  //initiate side effects to change text size when window width changes
  if (imageWidth.value < 150) {
    textRef.value.style.fontSize = '.8em'
    textRef.value.style.lineHeight = '1.3'
  }
  if (imageWidth.value < 200 && imageWidth.value > 150) {
    textRef.value.style.fontSize = '1em'
    textRef.value.style.lineHeight = '1.4'
  }
  if (imageWidth.value > 200) {
    textRef.value.style.fontSize = '1.3em'
    textRef.value.style.lineHeight = '1.5'
  }
})

Here is the finished example code using Vue 3 (and before I refactor it to use composables). Now that everything is working, I will refactor my code to make it more reusable.

Reusability in The Composition API

Many people would say that the biggest advantage of using Vue 3's Composition API is its emphasis on organizing code by logical concern rather than by option types like in Vue 2. If I'm building a small application that is only going to have minimal logic in a component, the Options API, or even just putting all my logic in the setup function, is fine. But as a component grows larger, it can be challenging to follow the data flow.

For example, a UI component such as a dropdown menu has to deal with opening and closing the dropdown, keyboard interactions, pulling in data to populate the menu, and more. All that logic in one component spread out among the options like methods, watch, mounted, etc., can be hard to decipher.

Vue 2 does offer approaches for separating out logic, such as mixins and utility functions. But Vue 3's whole philosophy is designed around the idea of writing code that is reusable, focused around logical concern, and easy to read. The most fundamental way it does this is through composition functions (i.e. composables).

Composables

The advantage of organizing code by logical concern encapsulated in a composable function is that it becomes easier to read, but it also becomes easier to reuse in other parts of the project or even in other projects.

I feel that the ultimate goal should be to write the most agnostic code possible in a composable, i.e. code that can be recycled in different contexts and isn't so dependent on the one unique context it starts out in.

It does take time and practice to get better at this skill, but the good news is, Vue 3 is the perfect framework to work at it because using the Composition API really emphasizes this approach to coding.

With that in mind, I'll think about how I can refactor my project to take advantage of composables.

useWindowEvent

A common situation is having to listen for an event on the window, such as a resize event. I see an opportunity to write a composable that can be reused when I want to add or destroy an event listener on the window.

In my project, in the onMounted hook I currently have:

window.addEventListener('resize', resizeHandler)

And in the unMounted hook:

window.removeEventListener('resize', resizeHandler)

I can create a composable function that accepts an event-type, a handler, and a string saying 'add' or 'destroy', and write logic that will set up the window event listener. I will put this file in a folder called ~/composables. The Vue 3 convention is to name composable files with the prefix 'use' as in useWindowEvent.

Here is the composable useWindowEvent.js:

export default function useWindowEvent(event, handler, addOrDestroy) {
  if (addOrDestroy === 'add') {
    window.addEventListener(event, handler)
  }

  if (addOrDestroy === 'destroy') {
    window.removeEventListener(event, handler)
  }
}

Now in my project, I import it into the component where it will be used:

import useWindowEvent from '../composables/useWindowEvent'

Then I invoke the function with the arguments that I set it up to receive:

useWindowEvent('resize', resizeHandler, 'add')

This is just a small composable, and it doesn't really make my life that much easier since I didn't have to write very much code anyways to set up the listener on the window.

But there is a significant advantage to creating reusable code. I know the composable is written to work, so I'm less likely to have little errors or typos since I'm reusing code that has been tested and used before. Because I've tested it, I can feel confident reusing it in many contexts.

Consistency is another benefit. I keep functionality consistent by using the composable in multiple places, rather than having to reinvent the wheel every time, potentially introducing differences (and problems).

And now that I have created a useWindowEvent, I could try to make it to work for all kinds of elements, not just the window. If I spend some time improving it so that it can add an event listener to any type of element, then I have a really useful composable that I can reuse.

useResizeText

The main feature of my project is that the text resizes based on the image element's width. I can turn this into a composable that can be reused in cases where I want text to resize based on some other element.

In my goal to write it in a way that is more agnostic, I can think of the element that is watched (the image) as the trigger element, and the element that changes (the text) as the react element. In the resizeText composable, I'll refer to them as the triggerElement and the reactElement, but in the Mast.vue component they are the imageRef and the textRef. These are more specific references to the context of my project, while triggerElement and reactElement are more general since I would like the composable to be reused if I ever need it in a different project.

I create the composable file called useResizeText.js. I anticipate that I'll need to accept two arguments, the triggerElement and the reactElement (which come in from Mast.vue as the imageRef and the textRef):

//useResizeText.js:

export default function useResizeText(triggerElement, reactElement) {
  return { elementWidth }
}

I've included the return object because any data from the composable that I want to make available in the component (or another file) must be included in it. I'll return the elementWidth to the component so I can put it in my template in Mast.vue and see the resize logic working in real-time.

In the Mast.vue component, I will call the composable. I have to send in the template refs so the composable can compute the text size based on those DOM elements. I will destructure the composable so that I get the returned elementWidth.

Inside setup in Mast.vue:

//destructure to get data sent back from the composable
//get updated width for template
const { elementWidth } = useResizeText(imageRef, textRef)

I will return elementWidth to the template so that I see that number reacting to the window resizing. I also return imageRef and textRef because that is required for the template refs to stay in-sync between the script and the template.

Here is everything in the setup function:

setup() {
    //template refs
    let imageRef = ref(null);
    let textRef = ref(null);
    //destructure to get data sent back from the composable
    //get updated width for template
    const { elementWidth } = useResizeText(imageRef, textRef);
    return { imageRef, textRef, elementWidth };
  },

The composable itself is mostly the same as it was when I wrote the logic in the setup function, with a few small updates.

To make sure I don't get an error when I set the elementWidth to the imageRef/triggerElement offsetHeight value, I use an 'if' statement to make sure the triggerElement exists:

if (triggerElement.value) {
  elementWidth.value = triggerElement.value.offsetWidth
}

I also set the initial text styles as soon as the component mounts and then run that setTextStyles function again inside the watch every time the elementWidth (the image's width) changes.

Here is the full code for the resizeText.js composable:

import { ref, watch, onMounted, onUnmounted } from 'vue'
import useWindowEvent from './useWindowEvent'

export default function useResize(triggerElement, reactElement) {
  let elementWidth = ref(0)

  //handler to send into useWindowEvent
  function resizeHandler() {
    if (triggerElement.value) {
      elementWidth.value = triggerElement.value.offsetWidth
    }
  }

  //set initial values for elementWidth and text styles
  onMounted(() => {
    if (triggerElement.value) {
      elementWidth.value = triggerElement.value.offsetWidth
      setTextStyles()
    }
  })

  //function to set text styles on mount and in watcher
  function setTextStyles() {
    if (elementWidth.value < 150) {
      reactElement.value.style.fontSize = '.8em'
      reactElement.value.style.lineHeight = '1.3'
    }
    if (elementWidth.value < 200 && elementWidth.value > 150) {
      reactElement.value.style.fontSize = '1em'
      reactElement.value.style.lineHeight = '1.4'
    }
    if (elementWidth.value > 200) {
      reactElement.value.style.fontSize = '1.3em'
      reactElement.value.style.lineHeight = '1.5'
    }
  }

  //add and destroy event listeners
  useWindowEvent('resize', resizeHandler, 'add')
  onUnmounted(() => {
    useWindowEvent('resize', resizeHandler, 'destroy')
  })

  //watch elementWidth and set text styles
  watch(elementWidth, () => {
    setTextStyles()
  })

  return { elementWidth }
}

This refactoring makes Mast.vue much easier to read because the logic for resizing the text and for adding a window event listener are separated out into composables.

However, my ultimate goal is to make composables that are more reusable in general. There is more I can do to make the resizeText composable reusable in other projects.

For example, I could set it up to take a breakpoints object, so that I don't have to always use the same hardcoded width sizes to influence the text.

I could also rework it accept a styles object for the text styles so that I'm not required to use the same hardcoded values for text styles for any component that uses the composable. Something like this in the component:

//constants
const breakPoints = { small: '100', medium: '150', large: '200' }
const textStyles = {
  fontSize: { small: '.8em', medium: '1em', large: '1.3em' },
  lineHeight: { small: '1.3', medium: '1.4', large: '1.5' },
}

Here is the full example.

There are still many ways to improve this composable to make it more agnostic, but this gives a general idea of the process that goes into making a composable more reusable.

Conclusion

This concludes my series on Diving into Vue 3. I have learned the fundamentals that will allow me to jump into building projects using the Composition API. I feel so much more confident in Vue 3 now, and I'm also really excited about it.

I hope you have enjoyed this series. There is always more to learn, so stay tuned for future posts about Vue topics.

Questions? Comments? Just want to say hi? You can find me on Twitter!

FEEDBACK