Interested in our next book? Learn more about Building Large-scale JavaScript Web Apps with React

Design Pattern

Composables

Options API

Before the introduction of the Composition API in Vue, developers relied on the Options API to organize component logic which include reactive data, lifecycle methods, computed properties, and more. The Options API allowed defining these aspects within specific options, as shown in the example below:

<!-- Template -->

<script>
  export default {
    name: "MyComponent",
    props: {
      // props
    },
    data() {
      // data
    },
    computed: {
      // computed properties
    },
    watch: {
      // properties to watch
    },
    methods: {
      // methods
    },
    created() {
      // lifecyle methods like created
    },
    // ...
  };
</script>

<!-- Styles -->

While this approach served its purpose and is still applicable in Vue v3, it can become challenging to manage and maintain as components grow larger and more complex. Defining component logic within specific options can make it harder to read and understand the code, especially when dealing with extensive components. Extracting and reusing common logic between components can also be difficult in this setup.

Let’s take a look at a simple example of an App component that renders two individual child components — Count and Width.

<template>
  <div class="App">
    <Count :count="count" :increment="increment" :decrement="decrement" />
    <div id="divider" />
    <Width :width="width" />
  </div>
</template>

<script>
  import Count from "./components/Count.vue";
  import Width from "./components/Width.vue";

  export default {
    name: "App",
    data() {
      return {
        count: 0,
        width: 0,
      };
    },
    mounted() {
      this.handleResize();
      window.addEventListener("resize", this.handleResize);
    },
    beforeUnmount() {
      window.removeEventListener("resize", this.handleResize);
    },
    methods: {
      increment() {
        this.count++;
      },
      decrement() {
        this.count--;
      },
      handleResize() {
        this.width = window.innerWidth;
      },
    },
    components: {
      Count,
      Width,
    },
  };
</script>

The code snippet above represents a Vue single-file component (SFC) named App.

The <template> section defines the markup of the component. In this case, it contains a <div> element with the class “App” that wraps two child components: <Count> and <Width>. These child components are passed certain properties using Vue’s attribute binding syntax (:count, :increment, :decrement, and :width).

The <script> section contains the JavaScript code for the component. It starts by importing the Count and Width components from their respective files. The export default statement is used to export the component definition. Within the component definition, we have:

  • The data method which returns an object containing the initial data properties of the component, which are count and width initialized to 0.
  • The mounted() lifecycle hook is used to execute code after the component has been mounted in the DOM. In this case, it calls the handleResize() method and adds an event listener for the resize event.
  • The beforeUnmount() lifecycle hook is used to execute code before the component is unmounted and destroyed. Here, it removes the event listener for the resize event.
  • The methods object contains the component’s methods. It defines increment(), decrement(), and handleResize() methods that manipulate the count and width data properties based on certain events or actions.
App.vue
1<template>
2 <div class="App">
3 <Count :count="count" :increment="increment" :decrement="decrement" />
4 <div id="divider" />
5 <Width :width="width" />
6 </div>
7 </template>
8
9 <script>
10 import Count from "./components/Count.vue";
11 import Width from "./components/Width.vue";
12
13 export default {
14 name: "App",
15 data() {
16 return {
17 count: 0,
18 width: 0,
19 };
20 },
21 mounted() {
22 this.handleResize();
23 window.addEventListener("resize", this.handleResize);
24 },
25 beforeUnmount() {
26 window.removeEventListener("resize", this.handleResize);
27 },
28 methods: {
29 increment() {
30 this.count++;
31 },
32 decrement() {
33 this.count--;
34 },
35 handleResize() {
36 this.width = window.innerWidth;
37 },
38 },
39 components: {
40 Count,
41 Width,
42 },
43 };
44 </script>

When the app is run, the current count and the window’s inner width are displayed in real-time. The user can interact with the component by incrementing and decrementing the count using the buttons in the <Count> component.

Incrementing and decrementing count

Similarly, the width is automatically updated whenever the window is resized.

Increasing and decreasing window width

The way the App.vue single-file component is structured can be visualized as the following:

Flow chart

Even though this component is small in size, the logic inside it is already intertwined. Some parts are dedicated to the functionality of the counter, while others pertain to the width logic. As the component grows, organizing and locating related logic within the component would become more challenging.

To address these challenges, the Vue team introduced the Composition API in Vue v3.

Composition API

The Composition API can be seen as an API that provides standalone functions representing Vue’s core capabilities. These functions are primarily used within a single setup() option which serves as the entry point for utilizing the Composition API.

<!-- Template -->

<script>
  export default {
    name: "MyComponent",
    setup() {
      // the setup function
    },
  };
</script>

<!-- Styles -->

The setup() function is executed before a component is created and when the props of the component are available.

With the Composition API, we can import standalone functions to help us access Vue’s core capabilities within our component. Let’s rewrite the counter and width example we’ve seen above while relying on the Composition API syntax.

<template>
  <div class="App">
    <Count :count="count" :increment="increment" :decrement="decrement" />
    <div id="divider" />
    <Width :width="width" />
  </div>
</template>

<script>
  import { ref, onMounted, onBeforeUnmount } from "vue";
  import Count from "./components/Count.vue";
  import Width from "./components/Width.vue";

  export default {
    name: "App",
    setup() {
      const count = ref(0);
      const width = ref(0);

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

      const decrement = () => {
        count.value--;
      };

      const handleResize = () => {
        width.value = window.innerWidth;
      };

      onMounted(() => {
        handleResize();
        window.addEventListener("resize", handleResize);
      });

      onBeforeUnmount(() => {
        window.removeEventListener("resize", handleResize);
      });

      return {
        count,
        width,
        increment,
        decrement,
      };
    },
    components: {
      Count,
      Width,
    },
  };
</script>

The <template> of our component remains the same but in the <script> section of our component, we now utilize the Composition API with the setup() function.

Inside the setup() function, we:

  • Define the count and width reactive variables using the ref() function — the function that accepts a single primitive value (e.g. string, number, etc.) and returns a reactive/mutable object.
  • We also define the custom functions increment(), decrement(), and handleResize(). These functions are similar to the methods we defined in our previous Options API example.
  • We use the onMounted() lifecycle function to call the custom handleResize() function and add an event listener for the resize event when the component is mounted. Similarly, we use the onBeforeUnmount() lifecyle function to remove the event listener for the resize event before the component is unmounted.
  • The reactive variables and functions defined in the setup() function are then returned, making them accessible in the component template.
App.vue
1<template>
2 <div class="App">
3 <Count :count="count" :increment="increment" :decrement="decrement" />
4 <div id="divider" />
5 <Width :width="width" />
6 </div>
7</template>
8
9<script>
10import { ref, onMounted, onBeforeUnmount } from "vue";
11import Count from "./components/Count.vue";
12import Width from "./components/Width.vue";
13
14export default {
15 name: "App",
16 setup() {
17 const count = ref(0);
18 const width = ref(0);
19
20 const increment = () => {
21 count.value++;
22 };
23
24 const decrement = () => {
25 count.value--;
26 };
27
28 const handleResize = () => {
29 width.value = window.innerWidth;
30 };
31
32 onMounted(() => {
33 handleResize();
34 window.addEventListener("resize", handleResize);
35 });
36
37 onBeforeUnmount(() => {
38 window.removeEventListener("resize", handleResize);
39 });
40
41 return {
42 count,
43 width,
44 increment,
45 decrement,
46 };
47 },
48 components: {
49 Count,
50 Width,
51 },
52};
53</script>

Composables

With our previous code example, one might still wonder how the setup() function offers any advantage to development since it appears that it just requires us to declare component options within a single function.

One of the fantastic benefits of adopting the composition API is the capability to extract and reuse shared logic between components. This is driven by the fact that we can simply declare functions of our own that use Vue’s globally available composition functions and have our functions be easily used in multiple components to achieve the same outcome.

Let’s take our previous counter and width example further by creating composable functions that encapsulates shared logic that can be reused across components.

First, let’s create a composable function called useCounter, a composable that encapsulates the counter functionality and returns the current value of count, an increment() method, and a decrement() method.

By convention, composable function names start with the “use” keyword.

import { ref } from "vue";

export function useCounter(initialCount = 0) {
  const count = ref(initialCount);

  function increment() {
    count.value++;
  }

  function decrement() {
    count.value--;
  }

  return {
    count,
    increment,
    decrement,
  };
}

Similarly, we can create a composable called useWidth() that encapsulates the width functionality of our app.

import { ref, onMounted, onBeforeUnmount } from "vue";

export function useWidth() {
  const width = ref(0);

  function handleResize() {
    width.value = window.innerWidth;
  }

  onMounted(() => {
    handleResize();
    window.addEventListener("resize", handleResize);
  });

  onBeforeUnmount(() => {
    window.removeEventListener("resize", handleResize);
  });

  return {
    width,
  };
}

In our App component, we can now use the composable functions to achieve the same outcome:

<template>
  <div class="App">
    <Count :count="count" :increment="increment" :decrement="decrement" />
    <div id="divider" />
    <Width :width="width" />
  </div>
</template>

<script>
  import Count from "./components/Count.vue";
  import Width from "./components/Width.vue";
  import { useCounter } from "./composables/useCounter";
  import { useWidth } from "./composables/useWidth";

  export default {
    name: "App",
    components: {
      Count,
      Width,
    },
    setup() {
      const { count, increment, decrement } = useCounter(0);
      const { width } = useWidth();

      return {
        count,
        increment,
        decrement,
        width,
      };
    },
  };
</script>

With these changes, our app will function the same as it did before but in a more composable setting.

App.vue
1<template>
2 <div class="App">
3 <Count :count="count" :increment="increment" :decrement="decrement" />
4 <div id="divider" />
5 <Width :width="width" />
6 </div>
7</template>
8
9<script>
10import Count from "./components/Count.vue";
11import Width from "./components/Width.vue";
12import { useCounter } from "./composables/useCounter";
13import { useWidth } from "./composables/useWidth";
14
15export default {
16 name: "App",
17 components: {
18 Count,
19 Width,
20 },
21 setup() {
22 const { count, increment, decrement } = useCounter(0);
23 const { width } = useWidth();
24
25 return {
26 count,
27 increment,
28 decrement,
29 width,
30 };
31 },
32};
33</script>

By using composable functions in the Composition API setting, we were able to break the context of our app down into smaller, reusable pieces that separated the logic.

Let’s visualize the changes we just made, compared to the initial Options API example component.

Flow chart

Using composable functions in Vue made it easier to separate the logic of our component into several smaller pieces. Reusing the same stateful logic now becomes easy since we are no longer confined to organizing our code within specific options in the Options API.

With composable functions, we have the flexibility to extract and reuse shared logic across components. This separation of concerns allows us to focus on specific functionality within each composable function making our code more modular and maintainable.

By breaking down the logic into smaller, reusable pieces, we can compose our components using these composable functions, bringing together the necessary functionality without duplicating code. This approach promotes code reusability and reduces the risk of code duplication and inconsistencies.

Additionally, using the Composition API provides better readability and understandability of the component’s logic. Each composable function encapsulates a specific aspect of the component’s behavior, making it easier to reason about and test. It also allows for easier collaboration among team members, as the code becomes more structured and organized.

Lastly, building Vue apps with the Composition API allows for better type inference. Since the Composition API helps us handle our component logic with variables and standard JavaScript functions, it becomes a lot easier to build large-scale Vue applications with a static type system like TypeScript!

Helpful Resources