Creating and Using Composables in Vue.js 3

Anton Ioffe - December 29th 2023 - 10 minutes read

As the landscape of web development continually evolves, Vue.js 3 stands at the forefront of this transformation, offering powerful new patterns for managing and encapsulating reactive logic. In this advanced exploration, we delve into the art of creating cutting-edge composables, examining everything from constructing a reactive mouse tracker to orchestrating intricate asynchronous data operations. Join us as we navigate through the best practices that seasoned developers employ when crafting these modular pieces and demonstrate their seamless integration, even within the familiar confines of the Options API. Prepare to elevate your Vue.js expertise by mastering the composable approach, ensuring your codebase remains as elegant as it is efficient.

Fundamentals of Composable Patterns in Vue.js 3

Composables in Vue.js 3 are akin to versatile tools, designed to encapsulate and manage reactive logic that can be distributed across multiple components. A fundamental characteristic of these composables is their ability to be either stateful or stateless. Stateful composables manage and track reactive state within the application. When extracting common reactive patterns such as user input or API call management, stateful composables shine by providing a singular source of truth. On the other hand, stateless composables are concerned purely with logic without a persistent state, often serving as utility functions that can transform data or execute isolated computational tasks.

The underpinning design principle of Vue composables is the reusability and modularization of code. By leveraging the Composition API, developers can write more organized and maintainable code, where composables act as independent units of functionality that are easily portable and integratable. This modularity also channels into a composable's capacity for nesting, where one composable may utilize another, creating a cohesive chain of logical processes. By building with smaller, focused functions, developers can construct complex features from simpler, well-defined building blocks, enhancing the code’s readability and making debugging more straightforward.

The reactivity system at the core of Vue's composables offers distinct advantages. It provides fine-grained control over the reactivity of values encapsulated within composables. Unlike global state management solutions that enforce reactive contexts broadly across the app, Vue composables offer the flexibility to finely tune the reactivity of certain parts as required. This granular control allows for performant updates by minimizing unnecessary rendering, and it avoids the pitfalls of prop-drilling and context wrapping that often plague complex applications.

Through the adoption of composables, developers reap the benefits of increased modularity and reusability. By encapsulating reactive states and functions into standalone composable units, features become more scalable and testable. Developers can more cleanly separate concerns, which aligns with modern development practices that favor maintainability and predictability over monolithic, tightly-coupled code blocks. Additionally, the compositional nature supports agile development methodologies, where iterative enhancements can be rolled out seamlessly with minimal impact on the larger application.

However, with such power comes the responsibility of understanding common coding mistakes associated with composables. One of the most common missteps is not properly managing the lifecycle of state within a composable, leading to memory leaks and inconsistent states. For example, failing to properly clean up side effects on unmounted may keep unwanted listeners active. A countermeasure is to ensure onUnmounted lifecycle hooks are used correctly to clear any subscriptions or event listeners established within a composable. Recognizing these pitfalls and judiciously applying lifecycle management principles helps maintain the integrity and performance of your Vue.js 3 applications.

Architecting a Reactive Mouse Tracker Composable

In the creation of a useMouse composable, we strive for a solution that is both efficient and elegant. Employing Vue's Composition API, we start by defining reactive variables with ref() to store the mouse coordinates. This ensures that any component consuming the composable remains reactive to changes of the mouse position:

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

export function useMouse() {
    const x = ref(0);
    const y = ref(0);

    function update(event) {
        x.value = event.pageX;
        y.value = event.pageY;
    }

    onMounted(() => window.addEventListener('mousemove', update));
    onUnmounted(() => window.removeEventListener('mousemove', update));

    return { x, y };
}

Notice the pattern here: the update function serves as the event handler for mouse movements. The imperative nature of event handling within reactive systems demands a seamless integration—a balance struck by using lifecycle hooks. By registering the handler on onMounted and deregistering on onUnmounted, we not only hook into the Vue lifecycle but also take charge of memory management, preventing potential leaks.

While the composable returns refs directly, we could alternatively return a reactive object using Vue's reactive() to provide de-structured reactivity if desired. However, this approach could introduce an extra layer of complexity with potential performance overhead due to deep reactivity. The direct refs, on the other hand, are lean and straightforward, yielding an efficient binding with minimal impact on memory:

import { reactive } from 'vue';

// Inside a consuming component
const mouse = reactive(useMouse());

One common pitfall in developing composables is to underestimate the implications of shared state. Each time useMouse is invoked in a component, distinct instances of x and y are generated. It's a nuanced but critical detail that ensures isolated state between instances, enabling multiple components to track the mouse independently without interference.

Lastly, when considering reusability and modularity, it's crucial to keep the composable focused on only what's necessary for tracking mouse position. Resist the urge to extend the composable with unrelated features. Adhering to the Single Responsibility Principle here not only simplifies the useMouse composable but also promotes its integration into more complex composables or larger systems, where diverse logic might converge.

Asynchronous Data Management with Composables

Managing asynchronous data in Vue.js 3 is an essential aspect of contemporary web development, sparking the need for composables adept in async tasks—such as fetching data from an API. A useAsync composable comes handy here. When you structure a composable to perform async operations, it typically involves a promise-based workflow. A common pattern is to expose loading, error, and result states, which components can react to.

Consider the orchestration involved: upon invoking an async function, we toggle loading to true, indicating that an action is in progress. Once the operation resolves, the result is populated and loading returns to false. In the case of an error, the error state is updated to reflect the issue. Here's an example of such a composable:

import { ref, readonly } from 'vue';

export function useAsync(asyncFunction) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);

  const execute = async (...args) => {
    loading.value = true;
    error.value = null;
    data.value = null;

    try {
      data.value = await asyncFunction(...args);
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  };

  return {
    data: readonly(data), 
    error: readonly(error),
    loading: readonly(loading),
    execute
  };
}

This pattern centralizes the async logic, aiding in avoiding boilerplate across components and improving maintainability. By having isolated state within the composable, components stay declarative, only concerned with the presentation of different data states.

Error handling in composables can be nuanced. While it is customary to catch errors and set an error state, it's also useful to consider re-throws to allow individual components the flexibility to handle specific errors uniquely. Programmatically, this means you might encapsulate the try-catch inside the composable, while still allowing a component to catch its own version of the error if it bubbles up.

Testing the useAsync composable is straightforward due to its decoupled nature. Here's a pattern for a unit test:

import { ref } from 'vue';
import { useAsync } from './useAsync';

it('executes the async function and updates the state correctly', async () => {
  const result = ref('test data');
  const asyncFunction = jest.fn().mockResolvedValue(result);
  const { data, error, loading, execute } = useAsync(asyncFunction);

  expect(loading.value).toBe(false);
  execute();
  expect(loading.value).toBe(true);

  // Wait for promise resolution
  await nextTick(); 
  expect(data.value).toBe(result);
  expect(loading.value).toBe(false);
  expect(error.value).toBeNull();
});

This test demonstrates how to mock an async function, invoke the composable's execute function, and assert that the loading, data, and error states are updated accordingly. This pattern is an excellent example of how composables can enhance testability in Vue.js applications.

In conclusion, the useAsync composable provides a structured and manageable approach to handling asynchronous operations in Vue.js. The proper segregation of concerns, error handling, and state encapsulation within composables lend to a robust architecture. Components can remain lean and focused, benefiting from the state management provided by the composable without becoming entangled in the intricate details of async data flows. The separation achieved through composables also opens up avenues for easier unit testing and potentially smoother transitions to server-side APIs.

Best Practices in Creating and Consuming Composables

When constructing composables, a clear and consistent naming convention is critical. It is widely adopted to prefix composable functions with use, followed by a descriptive action or object, adhering to camelCase naming. For example, useAxios for a composable handling HTTP requests. This preface not only indicates that the function is a composable but also aligns with the intuitive naming strategies embraced by the Vue community, easing collaboration and code discovery.

Handling inputs properly is crucial within composables. Accept both refs and plain values as arguments to ensure your composable is versatile. Utilize the toValue utility function to internally convert a ref to its value, providing uniformity inside the composable's logic. This approach decouples the composable from the source's reactivity and allows it easier to integrate in various contexts. Here's how you might handle arguments:

import { toRefs, toValue } from 'vue';

export function useExample(composableArg) {
  let argValue = toValue(composableArg);

  // Composable logic using argValue...
}

The data returned from a composable should be thoughtfully structured to enhance its utility while considering memory and performance implications. Prefer destructuring refs as it makes property sources clear in consumer components, and it's more efficient than a monolithic reactive object when properties are few. The pattern also allows for renaming properties to avoid conflicts and enhances tree-shaking by exposing only the used reactive state. Conversely, use reactive for returning a larger, interconnected state, making the refactoring process within the composable less cumbersome.

Composables should embody a balance between modularity and clarity. Avoid over-extracting logic to the point where code readability suffers or maintaining the composables becomes a complex task. Ensure each composable serves a distinct purpose and doesn't become bloated with unrelated functionality. By adhering to the principle of single responsibility, composables remain reusable and maintainable, with a clear understanding of their functionality.

As developers integrate composables, they should be wary of introducing unnecessary side effects. Side effects should be contained within lifecycle hooks such as onMounted and onUnmounted to reduce unexpected behaviors and ease testing. For instance, adding event listeners should be coupled with cleanup logic, preventing potential memory leaks. While composables provide a powerful abstraction, mismanagement of side effects can compromise the integrity and reliability of an application.

Leveraging Composables with the Options API

While Vue 3's Composition API ushers in a streamlined way to encapsulate and manage logic with composables, Vue 2 developers can also leverage this pattern when adopting Vue 3, especially when their application relies on the Options API. By utilizing the setup function within the Options API, developers can introduce composables into their existing components. The setup function acts as a bridge, allowing for the use of reactive features from the Composition API in an Options API context. It offers a space to define and return reactive data, making it accessible within the traditional Options API structure. Integrating composables in this manner allows for a gradual migration path without a complete rewrite of existing components, but developers should be aware of the nuances that come with mixing two architectural paradigms within the same component.

To integrate a composable in an Options API component, the pattern involves declaring reactive states or leveraging lifecycle hooks within the setup() function. This function then returns objects or functions that are used in the conventional component parts, such as methods, computed properties, or data. For example, a composable handling form validation logic might return a validateForm method, which can then be used within the component's methods. This setup preserves the readability and declarative nature of Vue, while also bringing in the reusable logic from the composable.

// In a Vue 2 component adopting Vue 3 composables
export default {
  name: 'MyComponent',
  setup() {
    const { validateForm } = useFormValidator();
    return { validateForm };
  },
  methods: {
    onSubmit() {
      if (this.validateForm(this.formData)) {
        // Handle valid form submission
      }
    },
  },
};

However, the inclusion of composables within the Options API doesn’t come without trade-offs. The main challenge lies in the cognitive overhead of managing two conceptual models within one component. Additionally, this approach demands a keen understanding of Vue's reactivity system to prevent performance bottlenecks and to ensure that reactivity is preserved across the different parts of the component. The developer's acuity in discerning when and how to apply composables within an older component architecture becomes crucial, especially when considering the interplay of reactivity between the Options and Composition APIs.

In light of these trade-offs, the decision to use composables in this way must be approached with a balance between the need for progressive enhancement and the simplicity of the codebase. If the component architecture becomes too convoluted, it may be a signal that a refactor toward exclusively using the Composition API is worthwhile. However, for applications that cannot be migrated all at once, this technique can extend the versatility and modular benefits of composables to the Options API, leading to an incremental modernization of the application.

Engaging with this type of integration invites developers to ponder the implications of a mixed architecture: When is the addition of composables within the Options API advantageous, and when might it introduce undue complexity? As new composables are incorporated, it's essential to assess their impact on the overall maintainability of the application, performance implications, and development experience. These considerations help in crafting a codebase that not only leverages the modular capabilities of Vue 3 but also aligns with the project’s broader architectural strategies.

Summary

This article explores the concept of creating and using composables in Vue.js 3, showcasing their benefits in modern web development. The article covers the fundamentals of composable patterns, such as stateful and stateless composables, and discusses best practices in creating and consuming them. It also demonstrates the implementation of a reactive mouse tracker composable and an asynchronous data management composable. The article concludes by discussing how to leverage composables in the Options API of Vue 2 and the challenges and trade-offs associated with this approach. The challenging technical task for the reader is to refactor a Vue 2 component using the Options API to integrate a composable from Vue 3's Composition API, considering the implications and maintaining a balance between progressive enhancement and code simplicity.

Don't Get Left Behind:
The Top 5 Career-Ending Mistakes Software Developers Make
FREE Cheat Sheet for Software Developers