DEV Community

Cover image for Mastering Vue 3 Composables: A Comprehensive Style Guide
Alexander Opalic
Alexander Opalic

Posted on • Edited on • Originally published at alexop.dev

Mastering Vue 3 Composables: A Comprehensive Style Guide

Introduction

The release of Vue 3 ushered in a transformational change, moving from the Options API to the Composition API. At the heart of this transition lies the concept of "composables" — modular functions that utilize Vue's reactive features. This change has injected greater flexibility and code reusability into the framework. However, it has also birthed challenges, notably the inconsistent implementation of composables across projects, which often results in convoluted and hard-to-maintain codebases.

This style guide aims to harmonize coding practices around composables, with a focus on producing clean, maintainable, and testable code. Though composables may appear to be a new beast, they are fundamentally just functions. Hence, this guide grounds its recommendations in time-tested principles of good software design.

Whether you're just stepping into Vue 3 or are an experienced developer aiming to standardize your team's coding style, this guide serves as a comprehensive resource.

Table of Contents

  1. File Naming
  2. Composable Naming
  3. Folder Structure
  4. Argument Passing
  5. Error Handling
  6. Avoid Mixing UI and Business Logic
  7. Anatomy of a Composable
  8. Functional Core, Imperative Shell
  9. Single Responsibility Principle
  10. File Structure of a Composable

File Naming

Rule 1.1: Prefix with use and Follow PascalCase

// Good
useCounter.ts
useApiRequest.ts

// Bad
counter.ts
APIrequest.ts
Enter fullscreen mode Exit fullscreen mode

Composable Naming

Rule 2.1: Use Descriptive Names

// Good
export function useUserData() {}

// Bad
export function useData() {}
Enter fullscreen mode Exit fullscreen mode

Folder Structure

Rule 3.1: Place in composables Directory

src/
└── composables/
    ├── useCounter.ts
    └── useUserData.ts
Enter fullscreen mode Exit fullscreen mode

Argument Passing

Rule 4.1: Use Object Arguments for Four or More Parameters

// Good: For Multiple Parameters
useUserData({ id: 1, fetchOnMount: true, token: 'abc', locale: 'en' });

// Also Good: For Fewer Parameters
useCounter(1, true, 'session');

// Bad
useUserData(1, true, 'abc', 'en');
Enter fullscreen mode Exit fullscreen mode

Error Handling

Rule 5.1: Expose Error State

// Good
const error = ref(null);
try {
  // Do something
} catch (err) {
  error.value = err;
}
return { error };

// Bad
try {
  // Do something
} catch (err) {
  console.error("An error occurred:", err);
}
return {};
Enter fullscreen mode Exit fullscreen mode

Avoid Mixing UI and Business Logic

Rule 6.2: Decouple UI from Business Logic in Composables

Composables should focus on managing state and business logic, avoiding UI-specific behavior like toasts or alerts. Keeping UI logic separate from business logic will ensure that your composable is reusable and testable.

// Good
export function useUserData(userId) {
  const user = ref(null);
  const error = ref(null);

  const fetchUser = async () => {
    try {
      const response = await axios.get(`/api/users/${userId}`);
      user.value = response.data;
    } catch (e) {
      error.value = e;
    }
  };

  return { user, error, fetchUser };
}

// In component
setup() {
  const { user, error, fetchUser } = useUserData(userId);

  watch(error, (newValue) => {
    if (newValue) {
      showToast("An error occurred.");  // UI logic in component
    }
  });

  return { user, fetchUser };
}

// Bad
export function useUserData(userId) {
  const user = ref(null);

  const fetchUser = async () => {
    try {
      const response = await axios.get(`/api/users/${userId}`);
      user.value = response.data;
    } catch (e) {
      showToast("An error occurred."); // UI logic inside composable
    }
  };

  return { user, fetchUser };
}
Enter fullscreen mode Exit fullscreen mode

Anatomy of a Composable

Rule 7.2: Structure Your Composables Well

A composable that adheres to a well-defined structure is easier to understand, use, and maintain. Ideally, it should consist of the following components:

  • Primary State: The main read-only state that the composable manages.
  • Supportive State: Additional read-only states that hold values like the status of API requests or errors.
  • Methods: Functions responsible for updating the Primary and Supportive states. These can call APIs, manage cookies, or even call other composables.

By ensuring your composables follow this anatomical structure, you make it easier for developers to consume them, which can improve code quality across your project.

// Good Example: Anatomy of a Composable
// Well-structured according to Anatomy of a Composable
export function useUserData(userId) {
  // Primary State
  const user = ref(null);

  // Supportive State
  const status = ref('idle');
  const error = ref(null);

  // Methods
  const fetchUser = async () => {
    status.value = 'loading';
    try {
      const response = await axios.get(`/api/users/${userId}`);
      user.value = response.data;
      status.value = 'success';
    } catch (e) {
      status.value = 'error';
      error.value = e;
    }
  };

  return { user, status, error, fetchUser };
}

// Bad Example: Anatomy of a Composable
// Lacks well-defined structure and mixes concerns
export function useUserDataAndMore(userId) {
  // Muddled State: Not clear what's Primary or Supportive
  const user = ref(null);
  const count = ref(0);
  const message = ref('Initializing...');

  // Methods: Multiple responsibilities and side-effects
  const fetchUserAndIncrement = async () => {
    message.value = 'Fetching user and incrementing count...';
    try {
      const response = await axios.get(`/api/users/${userId}`);
      user.value = response.data;
    } catch (e) {
      message.value = 'Failed to fetch user.';
    }
    count.value++;  // Incrementing count, unrelated to user fetching
  };

  // More Methods: Different kind of task entirely
  const setMessage = (newMessage) => {
    message.value = newMessage;
  };

  return { user, count, message, fetchUserAndIncrement, setMessage };
}
Enter fullscreen mode Exit fullscreen mode

Functional Core, Imperative Shell

Rule 8.2: (optional) use functional core imperative shell pattern

Structure your composable such that the core logic is functional and devoid of side effects, while the imperative shell handles the Vue-specific or side-effecting operations. Following this principle makes your composable easier to test, debug, and maintain.

Example: Functional Core, Imperative Shell

// good
// Functional Core
const calculate = (a, b) => a + b;

// Imperative Shell
export function useCalculatorGood() {
  const result = ref(0);

  const add = (a, b) => {
    result.value = calculate(a, b);  // Using the functional core
  };

  // Other side-effecting code can go here, e.g., logging, API calls

  return { result, add };
}

// wrong
// Mixing core logic and side effects
export function useCalculatorBad() {
  const result = ref(0);

  const add = (a, b) => {
    // Side-effect within core logic
    console.log("Adding:", a, b);
    result.value = a + b;
  };

  return { result, add };
}
Enter fullscreen mode Exit fullscreen mode

Single Responsibility Principle

Rule 9.1: Use SRP for composables

A composable should adhere to the Single Responsibility Principle, meaning it should have only one reason to change. In other words, a composable should have only one job or responsibility. A violation of this principle can result in composables that are difficult to understand, maintain, and test.

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

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

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

  return { count, increment, decrement };
}


// Bad

export function useUserAndCounter(userId) {
  const user = ref(null);
  const count = ref(0);

  const fetchUser = async () => {
    try {
      const response = await axios.get(`/api/users/${userId}`);
      user.value = response.data;
    } catch (error) {
      console.error("An error occurred while fetching user data:", error);
    }
  };

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

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

  return { user, fetchUser, count, increment, decrement };
}
Enter fullscreen mode Exit fullscreen mode

File Structure of a Composable

Rule 10.1: Rule: Consistent Ordering of Composition API Features

While the precise order can be adapted to meet the needs of your project or team, it is crucial that the chosen order is maintained consistently throughout your codebase.

Here's a suggestion for a file structure:

  1. Initializing: Code for setting up initialization logic.
  2. Refs: Code for reactive references.
  3. Computed: Code for computed properties.
  4. Methods: Functions and methods that will be used.
  5. Lifecycle Hooks: Lifecycle hooks like onMounted, onUnmounted, etc.
  6. Watch

this is just one example of a possible order, its just important that you have a order and ideally in your project the order is always the same

// Example in useCounter.ts
import { ref, computed, onMounted } from "vue";

export default function useCounter() {

  // Initializing
  // Initialize variables, make API calls, or any setup logic
  // For example, using a router
  // ...

  // Refs
  const count = ref(0);

  // Computed
  const isEven = computed(() => count.value % 2 === 0);

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

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

  // Lifecycle
  onMounted(() => {
    console.log("Counter is mounted");
  });

  return {
    count,
    isEven,
    increment,
    decrement,
  };
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The guidelines presented in this article aim to offer best practices for writing clean, testable, and efficient Vue 3 composables. While these recommendations stem from a mix of established software design principles and practical experience, they are by no means exhaustive or universally applicable.

Programming is often more of an art than a science. As you grow in your Vue journey, you may find different strategies and patterns that work better for your specific use-cases. The key is to maintain a consistent, scalable, and maintainable codebase. Therefore, feel free to adapt these guidelines according to the needs of your project.

I'm open to further ideas, improvements, and real-world examples. If you have any suggestions or different approaches that work well for you, please don't hesitate to share in the comments below. Together, we can evolve these guidelines to be an even more useful resource for the Vue community.

Happy coding!


Enjoyed this post? Follow me on X for more Vue and TypeScript content:

@AlexanderOpalic

Top comments (10)

Collapse
 
madeofclay profile image
Adam

I like where this article is going, and I have some notes/critiques.
Likes
I like Rule 4.1 as a guideline (object instead of many params); better scalability. I also strongly agree with 9.1 (SRP). It helps scope the work done by the composable (easier maintenance/debugging). 6.2 is another strong agree. 👍

Critiques
Rule 2.1's example is essentially restating 1.1. The only diff was the prefixing "use*", but that doesn't improve comprehension of the composable. Maybe a better example would be (bad) useData vs (good) useUserData. The ladder is more descriptive, which is 2.1's point.

8.2 is a good guideline (using functional programming core funcs), but the example is too contrived to see the value of the practice.

10.1's example where ref, computed, and functions are grouped can lead to the same problems that Options API did, but maybe the point is to keep composables small/simple enough where that concern is moot.

Comments
Rule 6.2 has no preceding 6.1 (nor does 7 & 8). That's a weird/confusing outline practice and looks accidental.

Also worth noting is 7.2's point on primary & supportive state being read-only. If that's a hard-and-fast goal, computed should be returned instead of ref, which the composable consumer can still change. I'd like to trust devs to follow good convention when they can do what's easier, but experience teaches me to be more pragmatic.

Overall, a good and thoughtful collection of composable conventions!

Collapse
 
alexanderop profile image
Alexander Opalic

Thank you for your comments. I did update some points regarding your feedback.

Regarding 8.2, I think the idea of functional programming is worth its own blog post to fully reap the benefits, so I agree with you.

Concerning 10.1, I did refactor a large composable, and grouping the elements helped. Of course, ideally, you shouldn't have a composable that is so large that you need to do this in the first place.

I agree with 7.2. You could use a computed property, but ultimately, the composable itself has to change a primary state somehow. You could have an internal user object and then export the same user as a computed property from the composable. However, I'm not sure if this approach is over-engineered, as I haven't seen this technique used elsewhere. But it's interesting to have immutable states for a composable.

In the end, with this blog post, I aimed to establish some guidelines that could be helpful, as I've noticed that developers often have differing views on composables. I believe there's a need for some kind of guide to unify these perspectives.

Collapse
 
thedamon profile image
Damon Muma

Great article!

I think 8.2 could be explained more deeply as functional/imperative is not a basic concept.

I also would love to see some examples that feel a bit more 'real life' and bring out the finer lines between good and bad.. i think the examples chosen are a bit on the obvious side (like the unrelated responsibility is too unrelated.. maybe make it logging in vs getting user data as those are two different things part of the same flow)

A follow up that talks about taking in reactive vs non reactive data and returning reactive data and a best practice structure of how to do that in almost every scenario would also be awesome.

Collapse
 
alexanderop profile image
Alexander Opalic

Thank you for your insightful comment!

I agree, section 8.2 could be elaborated further, and more real-world examples would enrich the discussion.

The idea behind this post was to summarise some best practices, but you've highlighted important areas that deserve their own posts. I'm intrigued by your suggestion on a follow-up post regarding reactive versus non-reactive data handling.

Stay tuned for more in-depth explorations in upcoming posts, and thanks again for your valuable feedback!

Collapse
 
acelin profile image
Ace Lin

Art! 👍 I like your blog.

Collapse
 
xpatriks profile image
Patrik

Also 6.2, you watch here for an error to come from composable.
What if there is more BE calls and more erros may appear in 1 composable? Then if you use the composable in multiple components, it may display the error many times... Any smart solution here?

Collapse
 
alexanderop profile image
Alexander Opalic

good question

I wrote a detailed blog post only about Error Handling

you can Check IT Out Here

dev.to/alexanderop/best-practices-...

Collapse
 
xpatriks profile image
Patrik

Why the file should be named with prefix "use"? Vue 3 docs itself is not using this for filenames.

Collapse
 
alexanderop profile image
Alexander Opalic

naming is not a hard rule, but your Project should have a better structure

I dont Like how the vue Docs IS doing IT

they have a Mouse.js File which Exports useMouse why not directly useMouse?

when you use vscode and you Type use you directly can than see all your composables

Collapse
 
lukkyjoe profile image
Joseph Luk

I love how you lay out not just examples but specifically good vs bad examples. Very helpful!