Francisco Brusa

Refactoring to Vue 3

Using the Composition API to express reusable stateful logic in VueJS

UI as a function of state

One of the core principles of React, Vue, Solid, Svelete, and similar libraries, is that the UI is a result of passing state through a function. (you don't even need a framework to apply this concept)

UI = fn(state);
UI = fn(state);

But what is state exactly? State is data that changes over time. In other words, a data stream. For example, if we have a Button that retrieves data from the server and updates a list of items, the state would be the retrieved data.

Visual representation of a data stream (described above)

Stateful logic is all the logic involved in mutating state.

In this example, we call stateful logic to the click event handler, the data fetching process, etc.

The stateful logic would include: the click stream, the retrieval of state from the server on click, and the logic that updates state when data is retrieved from the server

Stateful logic in Vue

Let's see how we could represent some simple stateful logic in a VueJS component.

We will do it "the old way" for now, and then refactor it to the composition API.

We will start with a component that shows the current window width.

<div>Window width is: {{ windowWidth }}</div>
<div>Window width is: {{ windowWidth }}</div>

The value of the window width is calculated when the component is mounted, but it's not updated if the user resizes the browser...

Let's fix that!

<div>Window width is: {{ windowWidth }}</div>
windowWidth: window.innerWidth,

We can add resize event handlers to our component.

window.removeEventListener("resize", this.calculateWindowWidth);
window.removeEventListener("resize", this.calculateWindowWidth);

It's a good practice to throttle the resize event, otherwise this component will update many times per second while the user is scrolling, and that might cause the experience to feel "laggy".

(In case you're not familiar with throttling and debouncing, here's a great CSS-tricks article on the subject).

We can add a throttled version of the calculateWindowWidth, and use it as the event listener function.

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
window.removeEventListener("resize", this.calculateWindowWidthThrottled);

By now our component has quite some stateful logic.

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
window.removeEventListener("resize", this.calculateWindowWidthThrottled);

To make this logic easily reusable, and to clean up our code, we will create a composition function.

A composition function (also known as a hook in React) is a way to encapsulate stateful logic so that it's reusable between components.

We will cover step-by-step how to create our composition function and replace all our stateful logic in the options part of our component.

(By the options part, we mean data(), mounted(), beforeDestroy(), etc).

By convention, the name of our function will start with use*.

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
function useWindowWidth() {}

To begin, let's define a windowWidth value using ref().

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
const windowWidth = ref(window.innerWidth);

We can already consume this composition function in our component and everything will keep working.

To do so, we will replace the data() section of our component with our composition function, called in setup().

Returning a ref from setup() is equivalent as declaring it in data().

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
const windowWidth = ref(window.innerWidth);

Let's continue. We have two methods in our component. Let's see how we could pass them to the composition function...

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
}, 200),

Since a method is just a function, to replace our methods we can create a function inside useWindowWidth, return it, and then consume it inside setup().

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
const { windowWidth, calculateWindowWidth } = useWindowWidth();

Notice how the windowWidth ref is mutated by changing it's value property.

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
windowWidth.value = window.innerWidth;

A very common mistake to make in the beginning is to change the windowWidth value directly. But this would make the object loose all reactivity.

Refs are actually objects that wrap values inside. There are good reasons why the Vue developers choose on these implementation details, you can read more about it here.

Just like with the calculateWindowWidth method, we can extract it's throttled version into the composition function.

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
const calculateWindowWidthThrottled = throttle(calculateWindowWidth, 200);

Finally, let's deal with these event listeners...

window.removeEventListener("resize", this.calculateWindowWidthThrottled);
window.removeEventListener("resize", this.calculateWindowWidthThrottled);

We can use onMounted and onBeforeUnmount to move this event listeners into our composable.

const calculateWindowWidthThrottled = throttle(calculateWindowWidth, 200);
const calculateWindowWidthThrottled = throttle(calculateWindowWidth, 200);

After all these changes, all the stateful logic is now encapsulated, abstracted away, inside useWindowWidth().

const calculateWindowWidthThrottled = throttle(calculateWindowWidth, 200);
const calculateWindowWidthThrottled = throttle(calculateWindowWidth, 200);

At the end, we only need to import the windowWidth variable from our composition function...

const calculateWindowWidthThrottled = throttle(calculateWindowWidth, 200);
</script>

In fact, we can extract our composition function into it's own file to clean up our component. (And to make the function easier to reuse in other components).

import { useWindowWidth } from "@/composables/useWindowWidth";
import { useWindowWidth } from "@/composables/useWindowWidth";

Many ways to reuse composition functions

Our useWindowWidth function now encapsulates all the stateful logic of the windowWidth state.

We can also reuse it in multiple components, just import { useWindowWidth } and you're good to go.

Now let's imagine we want to create a computed property that indicates if the user is in a desktop or a mobile device.

Additionally, we want to reuse this value in several components.

Using the options API, we would do something like this:

return this.windowWidth < 750
return this.windowWidth < 750

But that wouldn't be easy to reuse in other components. (You would have to copy and paste this computed every time).

Let's create a composition function instead...

export const useIsMobile = () => {};
export const useIsMobile = () => {};

For this function, we also need to keep track of the window width and update it when the user resizes the browser. We can just use the composable we already created!

import { useWindowWidth } from "@/composables/useWindowWidth";
import { useWindowWidth } from "@/composables/useWindowWidth";

Our useIsMobile composable can use windowWidth, literally just by calling useWindowWidth.

Finally, let's create a computed and return it. (We will import { computed } from "vue", which is the canonical way of creating computed properties inside composables).

import { useWindowWidth } from "@/composables/useWindowWidth";
import { useWindowWidth } from "@/composables/useWindowWidth";

Although this approach is fine, we can take advantage of the window.matchMedia browser API to compute this value; since this browser API has better performance.

You can read more about window.matchMedia in the MDN docs.

We could write a new composable to use this API, adding event listeners in the proper lifecycle events (mounted, unmounted), and so on...

But why do it if someone else already did it before?

Luckily, some npm libraries provide composition functions to wrap common browser APIs (and to do much more).

For example, we can useMediaQuery from the excellent vueuse library.

It's a wrapper around window.matchMedia. It will react to changes in the viewport (resizes) in a performant way.

const isMobile = useMediaQuery("(max-width: 750px)");
const isMobile = useMediaQuery("(max-width: 750px)");

As you can see, composition functions unlock several features in Vue that were not previously available.

  1. We get to reuse stateful logic.
  2. We can compose composition functions out of other composition functions.
  3. We can consume composition functions out of npm packages, and publish ours into the registry.
  4. And all this without loosing any features. Using functions like ref(), computed(), onMounted(), and many more, we can write eloquent code without needing to come back to the so-called options API.

If this sounds exiting and want to learn more, I recommend you to start by reading the Composition API introduction (from the official Vue documentation).

Migrating from Vue 2 to Vue 3

Now I know what you're thinking. This all sounds too good, but you're stuck in an old codebase that uses Vue 2 and migrating all your app at once is not possible 😰 ...

In order to make the migration to Vue 3 easier, the Vue Core team developed a standalone package that brings the composition API to Vue 2.

const windowWidth = ref(window.innerWidth);
const windowWidth = ref(window.innerWidth);

When updating to Vue 3, just change the import to:
import { ref } from "vue"
...and everything should keep working.

You can learn more about how to get started with the composition API plugin here

Wrapping up

We have covered a step by step process of refactoring a Vue component from the options API to the composition API.

The potential of the composition API is huge to make your code more reusable. Once you're familiar with this new API, you will see that it's a solid pattern that can scale well, and has even the potential to replace Vuex entirely from your application and become a centralized state management solution. (But that's maybe a topic for another article).

If you're starting with Vue 3, I recommend you to start writing some simple composition functions (or composables), and once you're comfortable with them, start writing all of your stateful logic this way.

Credits

Thanks to the React community for providing components to make this article more interactive on desktop. (And Yes, this is a Vue article written in React. Deal with it.)

In particular, the animations and scroll interactivity that you can see in this article (desktop only, sorry!) wouldn't have been possible without the work of Rodrigo Pombo (specially this conference talk).