Matthew Hodge
Full Stack Developer

Vue 3 Composables: Reusable Logic Without the Bloat

One of the biggest improvements in Vue 3 was the Composition API, and composables are the natural result of taking it seriously. If you've been using Vue 2 mixins to share logic between components, composables are the cleaner, more explicit replacement — and once you get the pattern, you'll use it everywhere.

In this post, I'll cover what composables are, how they compare to mixins, and walk through three practical examples: form handling, data fetching, and shared state.


What Is a Composable?

A composable is just a function that uses Vue's Composition API (ref, computed, watch, onMounted, etc.) and returns reactive state or methods. By convention, composable files live in a composables/ directory and are prefixed with use.

// composables/useCounter.js

import { ref } from 'vue'

export function useCounter(initial = 0) {
    const count = ref(initial)

    function increment() {
        count.value++
    }

    function decrement() {
        count.value--
    }

    return { count, increment, decrement }
}

Use it in any component:

<script setup>
import { useCounter } from '@/composables/useCounter'

const { count, increment, decrement } = useCounter(10)
</script>

<template>
    <div>
        <button @click="decrement">-</button>
        <span>{{ count }}</span>
        <button @click="increment">+</button>
    </div>
</template>

The logic is self-contained, clearly named, and not tangled up in the component.


Why Not Mixins?

Mixins had three major problems:

  1. Unclear source of truth — if a component uses three mixins and you see this.isLoading, which mixin does that come from? You have to dig.
  2. Namespace collisions — two mixins with a data property named the same thing silently conflict.
  3. No type support — mixins and TypeScript don't get along well.

Composables solve all three. Everything is explicitly destructured from the composable call, so you always know where a value comes from:

const { isLoading, data, error } = useFetch('/api/users')
const { form, submit, errors } = useForm({ name: '', email: '' })

No ambiguity, no collisions, and TypeScript works beautifully.


Practical Example 1: useForm

Form handling is probably the most common thing you'll pull into a composable. Tracking field values, validation errors, and submit state is boilerplate that shouldn't live in every component.

// composables/useForm.js

import { reactive, ref } from 'vue'

export function useForm(initialValues) {
    const form    = reactive({ ...initialValues })
    const errors  = reactive({})
    const loading = ref(false)

    function reset() {
        Object.keys(initialValues).forEach(key => {
            form[key] = initialValues[key]
        })
        Object.keys(errors).forEach(key => delete errors[key])
    }

    async function submit(url, options = {}) {
        loading.value = true

        Object.keys(errors).forEach(key => delete errors[key])

        try {
            const response = await fetch(url, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(form),
                ...options,
            })

            const data = await response.json()

            if (! response.ok && data.errors) {
                Object.assign(errors, data.errors)
                return null
            }

            return data
        } finally {
            loading.value = false
        }
    }

    return { form, errors, loading, reset, submit }
}

In a component:

<script setup>
import { useForm } from '@/composables/useForm'

const { form, errors, loading, submit, reset } = useForm({
    name: '',
    email: '',
})

async function handleSubmit() {
    const result = await submit('/api/users')

    if (result) {
        reset()
    }
}
</script>

<template>
    <form @submit.prevent="handleSubmit">
        <div>
            <input v-model="form.name" placeholder="Name" />
            <span v-if="errors.name">{{ errors.name[0] }}</span>
        </div>
        <div>
            <input v-model="form.email" placeholder="Email" type="email" />
            <span v-if="errors.email">{{ errors.email[0] }}</span>
        </div>
        <button type="submit" :disabled="loading">
            {{ loading ? 'Saving...' : 'Save' }}
        </button>
    </form>
</template>

The component is clean — all the plumbing is inside the composable.


Practical Example 2: useFetch

A data fetching composable handles loading state, errors, and the async lifecycle so you don't repeat it across every component that hits an API.

// composables/useFetch.js

import { ref, watchEffect, toValue } from 'vue'

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

    async function fetchData() {
        const resolvedUrl = toValue(url)

        if (! resolvedUrl) return

        loading.value = true
        error.value   = null
        data.value    = null

        try {
            const response = await fetch(resolvedUrl)

            if (! response.ok) {
                throw new Error(`HTTP error: ${response.status}`)
            }

            data.value = await response.json()
        } catch (err) {
            error.value = err.message
        } finally {
            loading.value = false
        }
    }

    watchEffect(fetchData)

    return { data, error, loading, refresh: fetchData }
}

Using toValue() on the URL means you can pass either a plain string or a ref — if it's a ref, the composable automatically re-fetches when the URL changes:

<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'

const userId = ref(1)
const { data: user, loading, error } = useFetch(() => `/api/users/${userId.value}`)
</script>

<template>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">{{ error }}</div>
    <div v-else-if="user">
        <h2>{{ user.name }}</h2>
        <p>{{ user.email }}</p>
    </div>
    <button @click="userId++">Next user</button>
</template>

Change userId and the fetch fires automatically — no manual watchers in the component.


Practical Example 3: Shared State Across Components

Composables can hold shared state when you create the reactive values outside of the function and return the same references each call. This is a lightweight alternative to Pinia for simple cases.

// composables/useAuth.js

import { ref, computed } from 'vue'

// Declared outside the function — shared across all callers
const user  = ref(null)
const token = ref(localStorage.getItem('token'))

export function useAuth() {
    const isAuthenticated = computed(() => !! token.value)

    async function login(credentials) {
        const response = await fetch('/api/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(credentials),
        })

        const data = await response.json()

        if (response.ok) {
            token.value = data.token
            user.value  = data.user
            localStorage.setItem('token', data.token)
        }

        return response.ok
    }

    function logout() {
        token.value = null
        user.value  = null
        localStorage.removeItem('token')
    }

    return { user, token, isAuthenticated, login, logout }
}

Any component calling useAuth() gets the same user and token refs. Update them in one place and every component that uses them reacts.

<!-- Navbar.vue -->
<script setup>
import { useAuth } from '@/composables/useAuth'
const { user, isAuthenticated, logout } = useAuth()
</script>

<template>
    <nav>
        <span v-if="isAuthenticated">Hello, {{ user.name }}</span>
        <button v-if="isAuthenticated" @click="logout">Log out</button>
    </nav>
</template>

Organising Composables

A few conventions that keep things tidy as you add more:

  • One composable per file, named after the composable function: useForm.js, useAuth.js
  • Keep composables focused — if a composable is growing a lot, consider splitting it
  • If a composable is component-specific (only used in one place), consider keeping it in the component file itself and only extracting when it's reused
  • TypeScript users: export an interface for the composable's return type so callers get full autocomplete

Final Thoughts

Composables make Vue 3 genuinely pleasant to work with at scale. Once you start thinking in composables, you'll find yourself pulling logic out of components naturally — and the components that remain are mostly just templates stitching together reactive values.

Start with something small like useToggle or useDebounce, get comfortable with the pattern, and work your way up to shared-state composables. It's one of those things that's hard to unsee once you've done it.


While computed properties are more appropriate in most cases, there are times when a custom watcher is necessary. That’s why Vue provides a more generic way to react to data changes through the watch option. This is most useful when you want to perform asynchronous or expensive operations in response to changing data. VueJS Watchers

html
<div id="watch_example">
  Kilograms : <input type="text" v-model="kilograms">
  Pounds : <input type="text" v-model="pounds">
</div>
javascript
var vm = new Vue({
  el: '#watch_example',
  data: {
    kilograms : 0,
    pounds: 0
  },
  methods: {
  },
  computed :{
  },
  watch : {
    kilograms: function(val) {
      this.kilograms = val;
      this.pounds = val * 0.453592;
    }
  }
});

In-template expressions are very convenient, but they are meant for simple operations. Putting too much logic in your templates can make them bloated and hard to maintain. That’s why for any complex logic, you should use a computed property. VueJS Computed

Example of good use of computed properties

<div id="computed_example">
  First Name : <input type="text" v-model="firstName">
  Last Name : <input type="text" v-model="lastName">
  Full Name : <input type="text" v-model="fullName">
</div>

Example of bad use of computed properties. Avoid too much logic in the template for example or this becomes increasingly verbose

<div id="computed_example">
  First Name : <input type="text" v-model="firstName">
  Last Name : <input type="text" v-model="lastName">
  Full Name : <input type="text" v-model="this.firstName + ' ' + this.lastName">
</div>
javascript
var vm = new Vue({
  el: '#computed_example',
  data: {
    firstName : "John",
    lastName: "Doe"
  },
  methods: {
  },
  computed :{
    fullName: function () {
        return this.firstName + ' ' + this.lastName
    }
  },
});