This content originally appeared on DEV Community and was authored by Gervais Yao Amoah
In the modern landscape of front-end development, particularly within the Vue 3 ecosystem, the concept of composables has revolutionized how developers structure and reuse stateful logic. Composables, which harness the power of the Composition API, are not merely utility functions; they are the cornerstone of building highly maintainable, testable, and scalable applications. By abstracting complex logic and state management from components, we empower our codebase to adhere to the fundamental "Don't Repeat Yourself" (DRY) principle, leading to cleaner, more efficient, and easier-to-understand code. This comprehensive guide will delve into some techniques and best practices we can employ to architect composables that are truly flexible and built for the long term.
What Exactly is a Vue Composable?
A composable in Vue is essentially a JavaScript function that leverages Vue's Composition API features (such as ref, reactive, computed, watch, and lifecycle hooks like onMounted and onUnmounted) to encapsulate and share stateful logic across components.
- Encapsulation: It bundles related reactive state and functions into a single, cohesive unit.
- Reusability: Once defined, a composable can be imported and used in any component, providing its specific logic instance to that component.
- Decoupling: It separates the business logic (the "what") from the component structure (the "how it's rendered"), significantly improving component readability and reducing complexity.
Think of a composable as a highly specialized custom hook or utility function for managing specific domain logic, like mouse tracking, local storage interaction, API data fetching, or form validation, that needs to be shared across various parts of the application without resorting to prop drilling or global state management for localized logic.
For example, a simple composable for managing a counter might look like this:
// useCounter.js
import { ref } from "vue";
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => count.value++;
const decrement = () => count.value--;
return { count, increment, decrement };
}
You can use this composable in any component:
<script setup>
import { useCounter } from "@/composables/useCounter";
const { count, increment, decrement } = useCounter(5);
</script>
The beauty of composables lies in code reusability and decoupled logic, which make applications easier to test, extend, and maintain.
Designing for Flexibility: The Art of Dynamic Arguments (ref and unref)
One of the most powerful features we can integrate into our composables is the ability to accept flexible arguments. In real-world applications, an input value for a composable might come in one of two forms: a simple primitive value (like a string or number) or an already established reactive reference (ref) from another part of the component or application state. A truly reusable composable should effortlessly handle both.
The Challenge of Consistency
When writing the core logic of a composable, we must decide whether to work with a raw value or a reactive reference. If we assume a raw value, passing a ref would necessitate using .value repeatedly inside the composable, which is cumbersome. If we assume a ref, passing a raw value would be impossible without explicitly wrapping it outside the composable.
The Solution: Intelligent Use of ref and unref
Vue provides two crucial utility functions to solve this problem elegantly: ref and unref. We use these functions strategically at the boundary of our composable to normalize the incoming arguments:
a. When a Reactive Reference is Always Needed (The ref Approach):
- If the composable's internal logic relies on the argument being a reactive reference (perhaps because we need to watch it for changes), we use the
refutility function on the input. - If a plain value is passed,
ref(value)converts it into a new, trackableref. - If an existing
refis passed,ref(existingRef)simply returns the originalrefinstance. - We ensure that inside the composable, we always interact with the argument using .value, because we have guaranteed it is a
ref.
b. When a Raw Value is Needed (The unref Approach):
- If the composable's logic primarily requires the raw, unwrapped value of the argument, we use the
unrefutility function. - If a reactive
refis passed,unref(ref)extracts and returns its .value. - If a plain value is passed,
unref(value)returns the value as is. - This is particularly useful when passing arguments to underlying non-reactive JavaScript functions or external libraries.
import { ref, unref } from "vue";
export function useSomething(input) {
const source = ref(unref(input));
function update(newValue) {
source.value = unref(newValue);
}
return { source, update };
}
By using these utilities, we create an exceptional developer experience (DX). The consumer of the composable doesn't need to worry about the internal state requirements; they can simply pass the data they have, whether it’s a ref or not, and our robust composable handles the conversion transparently. This elevates the reusability of the logic dramatically.
Maximizing Utility: Implementing Dynamic Return Values
The return signature of a composable should be as flexible as its arguments. While the Vue best practice typically recommends returning an object of reactive references (refs) to retain reactivity upon destructuring, there are many simple use cases where the consumer only needs a single, core value.
The Problem with "One-Size-Fits-All" Returns
Always returning a large object (even when only one value is required) can feel verbose and force the user to destructure for a single property, such as const { data } = useFetch(...). Conversely, only returning a single value restricts the consumer from accessing useful auxiliary values and methods (like isLoading, error, or refetch function).
The Solution: The Options Object
We implement a pattern, popularized by libraries like VueUse, where the composable's return value is conditional, dictated by an options object passed as an argument.
- Define a Control Option: We introduce an optional property, conventionally named
controls, within the options object. This property's presence (or a value oftrue) signals the consumer's intent to receive the full, expanded return object. - Default to Simplicity: By default, if the
controlsoption is not present or isfalse, the composable returns only its primary value: the most commonly needed reactive state (e.g., the fetched data, the counter value, the mouse coordinates). This is the simple interface for quick, minimal usage. - Return the Full Interface: If
controlsis explicitly set totrue, the composable returns a comprehensive return object. This object includes the primary value plus all the auxiliary state (isLoading,error, etc.) and any control methods (pause,resume,refetch, etc.). This is the full control interface for advanced usage.
// Example Implementation
export function useFetch(url, options = {}) {
const { controls = false } = options;
const data = ref(null);
const loading = ref(false);
const error = ref(null);
async function fetchData() {
loading.value = true;
try {
const res = await fetch(url);
data.value = await res.json();
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
if (controls) {
return { data, loading, error, fetchData };
} else {
return data;
}
}
This dynamic return pattern offers unparalleled flexibility and descriptiveness. It allows developers to choose the level of complexity they need, leading to cleaner component code and a highly optimized API surface for the composable itself.
Interface-First Design: Architecting for Intent
Before writing a single line of internal logic, we prioritize an interface-first design approach. A composable's value is directly tied to how intuitive and simple it is to use. The first step in creating an excellent composable is imagining how we would ideally consume it in a component.
The Essential Questions
We begin by establishing the contract between the composable and its consumer by asking a series of fundamental questions:
a. What Arguments Does It Receive?
- What are the mandatory inputs (e.g., an API URL, a
DOMelementref)? - Should these arguments be simple values or should they support reactive references (which we've already decided to handle with
ref/unrefnormalization)?
b. What options are in the Options Object?
- What configuration is necessary (e.g.,
throttledelay,deepwatcher, initialstate)? These should be grouped into a single, optional options object for clarity, especially when the number of parameters exceeds two. - What are the appropriate default values for each option to ensure the composable is usable with minimal configuration?
- Does it need the
controlsoption to enable the dynamic return pattern?
c. What Values Will It Return?
- What is the primary state (e.g.,
data,position,count)? - What are the necessary auxiliary states (e.g.,
isLoading,error,isFinished)? - What control methods are required for external manipulation (e.g.,
increment,start,reset)? - What should be the single-value return when the dynamic return is active?
By addressing these questions first, we define a clear, intentional API surface. This top-down approach ensures the composable's structure is driven by its utility in a component, rather than by the constraints of its internal implementation, resulting in a more intuitive and future-proof design.
Handling Asynchronicity: The "Async Without Await" Pattern
A significant challenge in writing composables, especially those that perform data fetching or other Promise-based operations, is integrating asynchronous logic without breaking Vue's reactivity context. Using await directly in the top level of a component's setup function or the composable's body can cause issues, as it pauses execution, potentially leading to lifecycle hooks and reactive effects not being correctly registered to the current component instance.
The Problem with await in Setup Context
When setup is defined as an async function, the component rendering proceeds immediately, but any code following an await within the setup function executes after the component has mounted. Consider this example:
<script setup>
// ...
const data = await fetchData();
// ...
</script>
This line pauses execution of the setup function until the data is fetched, meaning no reactive state updates can occur until then. It’s not ideal for responsive UI.
The Solution: The "Async Without Await" Pattern
The key to mastering async composables is to ensure that all reactive state and lifecycle hooks are defined and returned synchronously, before any await occurs. The asynchronous operation itself is then executed "in the background," and its result is used to update the reactive state.
- Synchronous State Initialization: We start by defining all necessary reactive state (
data,isLoading,error) usingrefand immediately return these references along with any synchronous control methods. This ensures the component receives trackable state from the get-go. - Background Execution: The
Promise-returning function (e.g., afetchcall) is executed without a "top-level"await. - Reactive Update: Inside a
.then()ortry/catchhandler, we update the synchronously returnedrefs(e.g.,data.value = result). Because theserefsare already being tracked by Vue and are linked to the component's template, the component will automatically re-render with the fetched data as soon as thePromiseresolves.
// Example of useFetch composable implementing "Async Without Await"
import { ref } from "vue";
export function useFetch(url: string | Ref<string>) {
const data = ref(null);
const error = ref(null);
const isLoading = ref(true);
// Synchronous execution function
const executeFetch = async (currentUrl: string) => {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(currentUrl);
if (!response.ok) throw new Error(response.statusText);
const json = await response.json();
data.value = json; // Reactive state update
} catch (e) {
error.value = e; // Reactive state update
} finally {
isLoading.value = false; // Reactive state update
}
};
// We can use watchEffect or a similar mechanism if the URL is reactive
// and we want to re-fetch on change. If not, just execute once.
executeFetch(url); // Execute asynchronously in the background
// Crucially, all state is returned synchronously
return { data, error, isLoading };
}
This pattern guarantees a clean, predictable, and non-blocking user interface flow, as the component is able to render a loading state immediately, and its final content flows in naturally due to Vue's powerful reactive system. By rigorously applying this pattern, we ensure our asynchronous composables are fully maintainable and free of subtle Vue context issues.
Conclusion
Designing reusable and maintainable Vue composables is not just about writing functions; it’s about crafting flexible, intuitive, and scalable building blocks for your application.
By focusing on usage first, embracing argument flexibility, implementing dynamic return patterns, and mastering non-blocking async handling, you can elevate your composables from simple utilities to powerful architecture tools.
With thoughtful design and consistent structure, your Vue composables will not only enhance productivity but also ensure long-term maintainability for your entire team.
This content originally appeared on DEV Community and was authored by Gervais Yao Amoah
Gervais Yao Amoah | Sciencx (2025-10-24T15:25:36+00:00) A Guide to Reusable and Maintainable Vue Composables. Retrieved from https://www.scien.cx/2025/10/24/a-guide-to-reusable-and-maintainable-vue-composables/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.