Vue 3 Composables: Your Guide to Reusable Logic

By LearnWebCraft Team13 min readIntermediate
Vue 3ComposablesComposition APIVue.jsReusable Logic

Let’s be real for a second. If you’ve worked with Vue 2 for any serious amount of time, you’ve felt the pain. You build this beautiful, complex component. It's got some clever logic for fetching data, maybe some state for a modal, and a bit of code to track the window size. It works perfectly.

And then, the dreaded request comes: "Hey, can we use that same fetching logic over in this other component?"

Your heart just sinks a little, right? You know what's next. You copy-paste. Then you copy-paste again. Or, if you're feeling fancy, you reach for a mixin. And suddenly, you're debugging where this.data is even coming from, fighting naming collisions, and trying to trace logic that feels like it’s teleporting into your component from another dimension. It was a mess. Trust me, we’ve all been there.

The arrival of the Composition API in Vue 3 wasn't just another new feature; it felt like a life raft. And at the heart of it all is a pattern so elegant, so simple, it feels like it should have existed forever: Vue 3 Composables.

These aren't just functions. They are your new superpowers. They’re the secret to writing code that’s clean, easy to reason about, and—dare I say it—actually joyful to reuse.

So, What Exactly IS a Composable?

Okay, let's pull back the curtain on this. At its core, a composable is just a function. That’s it. Seriously, no magic.

But it’s a special kind of function. It’s a function that hooks into Vue’s reactivity system (using APIs like ref, computed, watch, etc.) to encapsulate and reuse stateful logic.

Think of it like this:

  • A regular utility function might take two numbers and return their sum. It’s pure, predictable, and has no memory of what happened before.
  • A composable, on the other hand, might track the user's mouse position. It has its own internal state (x and y coordinates), and that state is reactive. When the mouse moves, the state updates, and any component using it just… reacts.

The convention is to name them with the word "use" at the beginning, like useMouse() or useFetch(). This is a little nod to React Hooks, and it signals to other developers, "Hey, this function contains reactive logic!"

Let's Build Our First One: The Classic useCounter

Every journey starts with a single step, and in the world of composables, that first step is usually a counter. It’s the "Hello, World!" of reusable reactive logic. It’s simple, I know, but it perfectly shows off the core pattern.

Let's go ahead and create a file, composables/useCounter.js.

// composables/useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  // 1. Encapsulated, reactive state
  const count = ref(initialValue);

  // 2. Computed property based on our state
  const doubleCount = computed(() => count.value * 2);

  // 3. Methods to manipulate the state
  function increment() {
    count.value++;
  }

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

  // 4. Expose the state and methods
  return {
    count,
    doubleCount,
    increment,
    decrement,
  };
}

Take a look at that. It's just a plain JavaScript function. There's no this to worry about.

  1. We create a reactive piece of state called count using ref. It's completely self-contained within this function's scope.
  2. We derive a computed value from it.
  3. We define a couple of functions that change our count.
  4. And here's the key part—we return everything our component will need as a plain object. We're explicitly exposing our API.

Now, how do we use this little bundle of logic? It's almost anticlimactic how easy it is.

<!-- components/MyCounter.vue -->
<script setup>
import { useCounter } from '@/composables/useCounter';

// Use it!
const { count, doubleCount, increment } = useCounter(10);
</script>

<template>
  <div>
    <p>Current Count: {{ count }}</p>
    <p>Double that is: {{ doubleCount }}</p>
    <button @click="increment">Add One</button>
  </div>
</template>

And boom. We just imported a function, called it, and destructured the reactive goodies it gave back to us. Our component doesn't need to know how the counter works; it just knows it has a count ref and an increment function to call. It’s clean, explicit, and beautifully decoupled.

You could have another component using useCounter(100) right next to this one, and they wouldn't interfere at all. Each call to useCounter creates its own independent, reactive scope. This is an absolute game-changer.

Diving Deeper: Practical, Real-World Composables

Okay, a counter is cute, but let's solve some real problems. This is where Vue 3 composables truly start to shine.

Tracking the Window Size with useWindowSize

How many times have you written logic to listen for the resize event on the window object? And how many times have you forgotten to remove that event listener when the component is unmounted, causing a subtle memory leak? I'll raise my hand here—guilty as charged.

Let's build a composable to handle this perfectly, every single time.

// composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useWindowSize() {
  const width = ref(window.innerWidth);
  const height = ref(window.innerHeight);

  function update() {
    width.value = window.innerWidth;
    height.value = window.innerHeight;
  }

  // Lifecycle hooks inside a composable!
  onMounted(() => window.addEventListener('resize', update));
  onUnmounted(() => window.removeEventListener('resize', update));

  return { width, height };
}

Did you catch that? We can use Vue’s lifecycle hooks (onMounted, onUnmounted) right inside our composable. When a component uses useWindowSize, these hooks are automatically attached to that component's lifecycle.

So, when the component mounts, we add the listener. When it unmounts, we automatically clean it up. It's basically foolproof.

Usage:

<!-- components/ResponsiveLayout.vue -->
<script setup>
import { computed } from 'vue';
import { useWindowSize } from '@/composables/useWindowSize';

const { width } = useWindowSize();

const isMobile = computed(() => width.value < 768);
</script

<template>
  <div>
    <h1 v-if="isMobile">Welcome to the Mobile Site!</h1>
    <h1 v-else>Welcome to the Desktop Experience!</h1>
    <p>Current width: {{ width }}px</p>
  </div>
</template>

Our component’s job is now dead simple: consume the reactive width and make decisions based on it. All the messy, imperative DOM event handling is tucked away neatly.

Taming Asynchronous State with useFetch

Ah, data fetching. The source of so, so many bugs. You need to track loading state, error state, and the final data itself. It's a classic state machine problem that, if we're honest, we often solve pretty sloppily.

I feel like I've written this exact logic hundreds of times. A loading ref, an error ref, a data ref... it's just begging to be extracted. So, let's do it.

// composables/useFetch.js
import { ref, watch, toValue } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);

  async function doFetch() {
    // Reset state before fetching
    loading.value = true;
    error.value = null;
    data.value = null;
    
    // The toValue utility unwraps potential refs or getters passed as the URL
    const urlValue = toValue(url);

    try {
      const res = await fetch(urlValue);
      if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status}`);
      }
      data.value = await res.json();
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  }

  // If the URL is a ref, watch for changes and re-fetch
  watch(url, doFetch, { immediate: true });

  return { data, error, loading, refetch: doFetch };
}

This one is a bit of a beast, but it's a beautiful one. It takes a URL (which can be a string or even a ref or computed property!) and handles the entire lifecycle of a network request. That toValue helper from Vue is fantastic here—it lets our composable accept both static strings and reactive sources for the URL without any extra fuss on our part.

And that watch is the real secret sauce. If we pass in a reactive URL (like a computed property that changes when an ID changes), the composable will automatically refetch the data for us. How cool is that?

Using our new workhorse:

<!-- components/UserProfile.vue -->
<script setup>
import { ref, computed } from 'vue';
import { useFetch } from '@/composables/useFetch';

const userId = ref(1);
const apiUrl = computed(() => `https://jsonplaceholder.typicode.com/users/${userId.value}`);

// Here's the magic.
const { data: user, error, loading } = useFetch(apiUrl);
</script>

<template>
  <div>
    <h2>User Profile</h2>
    <div>
      <button @click="userId--">Prev</button>
      <span>User ID: {{ userId }}</span>
      <button @click="userId++">Next</button>
    </div>

    <div v-if="loading">Loading...</div>
    <div v-else-if="error" style="color: red;">Oops! {{ error.message }}</div>
    <div v-else-if="user">
      <h3>{{ user.name }}</h3>
      <p>Email: {{ user.email }}</p>
      <p>Website: {{ user.website }}</p>
    </div>
  </div>
</template>

Our component's <script setup> block is now stunningly declarative. It describes what data it needs, not how to get it. When we click that "Next" button, userId changes, which causes apiUrl to re-evaluate, which our useFetch composable detects, triggering a new fetch. It’s a beautiful, reactive chain reaction.

Persisting State with useLocalStorage

Alright, let's do one more common one. Ever wanted to remember a user's preference, like a dark mode setting or the contents of a form they haven't submitted yet? localStorage is perfect for that, but interacting with it can be a bit clunky. You have to JSON.stringify, then JSON.parse, and remember to update it whenever your state changes.

Let's make a composable that feels just like using a ref, but magically syncs with localStorage.

// composables/useLocalStorage.js
import { ref, watch } from 'vue';

export function useLocalStorage(key, defaultValue) {
  // Try to get the initial value from localStorage, or use the default
  const initialValue = JSON.parse(localStorage.getItem(key)) ?? defaultValue;

  const data = ref(initialValue);

  // Watch for changes in our ref and write them to localStorage
  watch(
    data,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue));
    },
    { deep: true } // 'deep' is important for objects and arrays!
  );

  return data;
}

This composable is so clever, isn't it? It reads from localStorage just once to initialize its state, and then uses a watch to automatically write back any time the ref changes. That { deep: true } option is crucial for when we're storing objects, ensuring the watcher fires even if a nested property changes.

Usage:

<!-- components/ThemeSwitcher.vue -->
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage';

// This 'theme' ref is now magically persistent!
const theme = useLocalStorage('app-theme', 'light');

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light';
}
</script>

<template>
  <div :class="theme">
    <p>Current theme is: {{ theme }}</p>
    <button @click="toggleTheme">Toggle Theme</button>
  </div>
</template>

Refresh the page. The theme stays. Close the browser and come back. The theme is still there. Our component code is blissfully unaware of localStorage. It just thinks it's using a normal ref. That’s the power of good abstraction right there.

Best Practices and The "Gotchas"

As with any powerful tool, there are a few rules of the road. I've learned these the hard way, so hopefully you don't have to.

  1. Always Call Them at the Top Level: This is a big one, just like with React Hooks. Don't call composables inside conditionals (if) or loops. The internal lifecycle hooks depend on a consistent call order every time the component sets up. Just keep them at the top level of your setup function.

  2. The use Prefix is Your Friend: Seriously, stick to the useSomething naming convention. It’s a clear signal to everyone (including future you) that the function is a composable and follows these rules.

  3. Think in Inputs and Outputs: A good composable takes some arguments (ideally refs or plain values) and returns reactive state and functions. Try to avoid having your composable reach "outside" to modify some global state. Keep it self-contained and predictable.

  4. Clean Up After Yourself: If you're setting up anything that needs to be torn down (like event listeners, timers with setInterval, or WebSocket connections), always use onUnmounted to handle the cleanup. Our useWindowSize is a perfect example of this done right.

  5. Return refs, Not Plain Values: If you return a plain value from a composable and try to destructure it, you'll lose reactivity. The official Vue docs have great examples on this. Always return the ref itself (e.g., return { count }) or use reactive for objects.

The End of Mixins, The Beginning of a New Era

So, are composables just mixins with extra steps? Oh, absolutely not. They solve all the major problems we had with mixins:

  • Source is Clear: When you use { count, increment } in your template, you know exactly where it came from: your call to useCounter(). With mixins, properties would just magically appear on this, leaving you to guess their origin.
  • No Namespace Collisions: You can use two composables that both happen to expose a data property. It's no big deal! Just rename them on destructuring: const { data: userData } = useUser(); const { data: postData } = usePosts();. Problem solved. With mixins, one would silently overwrite the other, leading to some very confusing bugs.
  • Explicit State: Composables return values. You explicitly choose what to use from them. Mixins just kind of dumped everything into your component, whether you needed it or not.

This isn't just a different syntax; it's a fundamentally better way to structure and share code in your applications. You can start building your own library of use functions—useFormValidation, useClipboard, useAnimation—that will speed up your development and make your codebase a genuine joy to work in.

Give it a try. Find a piece of logic you've written more than once in your project. Extract it into a useSomething() function. I promise, you'll have an "aha!" moment, and you'll never want to go back.

Frequently Asked Questions

Can I use composables in the Options API?

You can, but it's a bit more verbose. You'd call them within the setup() option of a component. Honestly, though, they are designed to work most seamlessly with <script setup>, which is the recommended syntax for Vue 3 for a reason. The Composition API really is the future, and it's worth embracing!

Are Vue Composables the same as React Hooks?

They are very similar in spirit and they solve the same core problem: reusing stateful logic. Both are just functions you call inside a component's setup context. The main difference really lies in the underlying reactivity model. React re-runs the whole function component to update, while Vue uses its fine-grained reactivity system where refs and computed properties trigger updates only when their specific dependencies change.

Where should I put my composable files?

A common and really helpful convention is to create a /src/composables directory in your project. This keeps all your reusable logic neatly organized in one place, making it easy for you and your team to find and import them wherever you need them.

Can a composable use another composable?

Absolutely! This is one of their most powerful features. For example, you could create a useInfiniteScroll composable that internally uses our useFetch composable to load pages of data and maybe a useIntersectionObserver to detect when the user scrolls to the bottom. This is where the "composition" in Composition API really clicks and starts to feel like a superpower.

Related Articles