The Dev Log › Programming
Vue 3 Composables: Write Once, Reuse Everywhere
By Jezer Niel Blanca, Full Stack Developer ·
·
4 min read
How I structure Vue 3 composables so stateful logic stays small, testable, and shareable across every page of an Inertia app.
Composables are my favorite part of Vue 3. They let me pull stateful logic out of components and reuse it anywhere, without mixins, without inheritance, and without magic. Here's how I think about them and a few I end up writing on nearly every project.
What a Composable Actually Is
A composable is just a function that uses Vue's reactivity APIs and returns reactive state or functions. By convention the name starts with use:
import { ref } from 'vue';
export function useToggle(initial = false) {
const isOn = ref(initial);
const toggle = () => (isOn.value = !isOn.value);
return { isOn, toggle };
}
That's it. No registration, no plugin. Import it and call it inside <script setup>.
Where I Put Them
In an Inertia app I keep composables in resources/js/composables, one per file, named after the function. That makes them easy to find and easy to test.
If logic appears in two components, it's a candidate. If it appears in three, it's a composable.
Cleaning Up After Yourself
Composables that add listeners, timers, or observers must clean up. Use lifecycle hooks inside the composable itself so consumers don't have to remember:
import { onMounted, onBeforeUnmount, ref } from 'vue';
export function useWindowWidth() {
const width = ref(window.innerWidth);
const update = () => (width.value = window.innerWidth);
onMounted(() => window.addEventListener('resize', update));
onBeforeUnmount(() => window.removeEventListener('resize', update));
return { width };
}
Accept Refs or Plain Values
Good composables work whether you pass a raw value, a ref, or a getter. Vue's toValue helper normalizes all three:
import { ref, toValue, watchEffect } from 'vue';
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
const isLoading = ref(false);
watchEffect(async () => {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(toValue(url));
data.value = await response.json();
} catch (caught) {
error.value = caught;
} finally {
isLoading.value = false;
}
});
return { data, error, isLoading };
}
Because watchEffect tracks toValue(url), passing a ref or getter makes the fetch re-run automatically when the URL changes.
A Debounced Search for Inertia Pages
This is one I copy into almost every admin panel. It keeps a search box in sync with the URL using Inertia's router:
import { ref, watch } from 'vue';
import { router } from '@inertiajs/vue3';
export function useSearch(initial = '', delay = 300) {
const query = ref(initial);
let timer;
watch(query, (value) => {
clearTimeout(timer);
timer = setTimeout(() => {
router.get(window.location.pathname, { search: value || undefined }, {
preserveState: true,
preserveScroll: true,
replace: true,
});
}, delay);
});
return { query };
}
And using it in a page:
<script setup>
import { useSearch } from '@/composables/useSearch';
const props = defineProps({ filters: Object, users: Array });
const { query } = useSearch(props.filters.search ?? '');
</script>
<template>
<div>
<input v-model="query" type="search" placeholder="Search users..." />
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
Shared State Without a Store
If you declare state outside the function, every caller shares it. That's a lightweight alternative to a full store for small, app-wide state:
import { ref, readonly } from 'vue';
const toasts = ref([]);
export function useToasts() {
function push(message, type = 'success') {
const id = crypto.randomUUID();
toasts.value.push({ id, message, type });
setTimeout(() => dismiss(id), 4000);
}
function dismiss(id) {
toasts.value = toasts.value.filter((toast) => toast.id !== id);
}
return { toasts: readonly(toasts), push, dismiss };
}
Returning readonly(toasts) means components can display toasts but can only change them through push and dismiss.
Rules I Follow
- Return an object of refs, not a reactive object, so destructuring keeps reactivity.
- Keep each composable focused on one concern.
- Clean up side effects inside the composable.
- Call composables synchronously at the top of
<script setup>, never inside callbacks.
- Reach for Pinia only when shared state becomes complex or needs devtools.
Testing Them
Pure composables can be tested like any function. For ones that use lifecycle hooks, mount a tiny wrapper component in your test. Either way, the logic lives in one place, so one test covers every component that uses it.
Final Thoughts
Composables changed how I structure front ends. Components became mostly templates, and logic became small, named, and reusable.
Working on a Vue or Inertia app that's getting hard to maintain? Let's talk. Untangling front ends is one of my favorite kinds of work.
Tags: Vue, Composables, JavaScript, Inertia