Stop the Copy-Paste: A Practical Guide to Vue Composables
Stop the Copy-Paste: A Practical Guide to Vue Composables
You’re building a dashboard. You’ve got a product list that needs infinite scrolling, a comment section that needs infinite scrolling, and a search result page that… you guessed it.
Before you know it, you’ve copy-pasted the same IntersectionObserver logic and the same three ref variables into half a dozen components. Your codebase is starting to feel like a house of cards—if you decide to change the “offset” for when the next page loads, you have to find and update it in four different files.
This is the “Logic Entanglement” trap. In the Vue 2 days, we tried to fix this with Mixins, but they just made things worse by hiding where variables even came from. Composables are the tool we actually needed: a way to wrap up stateful logic so it’s clean, reusable, and doesn’t break when you move it between files.
What are we actually talking about?
A Composable is just a function that uses Vue’s reactivity system to handle a specific job. It’s not just a helper function that does math; it’s a piece of “headless” logic. It has its own state (ref), its own logic, and it can even tap into the component lifecycle (onMounted).
Think of it as moving the “brain” (the logic) out of the component so you can plug it into any “body” (the UI) you want.
Example: The “Infinite Scroll” Brain
Instead of manually setting up observers in every list component, we can wrap that logic into a useInfiniteScroll composable.
// src/composables/useInfiniteScroll.js
import { ref, onMounted, onUnmounted, toValue } from 'vue';
export function useInfiniteScroll(target, callback) {
const isIntersecting = ref(false);
let observer = null;
const stop = () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
onMounted(() => {
// 1. We extract the actual element from the ref using toValue
const element = toValue(target);
if (!element) return;
// 2. Setup the browser's Intersection Observer
observer = new IntersectionObserver(([entry]) => {
isIntersecting.value = entry.isIntersecting;
if (entry.isIntersecting) {
callback(); // Trigger the next page load
}
});
observer.observe(element);
});
// 3. Cleanup: If the user leaves the page, kill the observer
onUnmounted(() => stop());
return { isIntersecting, stop };
}
Now, in your component, you just point it at a “sentinel” element (like a loading spinner at the bottom of the list) and tell it what function to run.
Using it as a Bridge (Options API)
You don’t have to switch your entire project to <script setup> to start cleaning things up. You can use the setup() hook as a bridge. Anything you return from it becomes available on this, just like your regular data and methods.
import { useInfiniteScroll } from './composables/useInfiniteScroll';
export default {
setup() {
const loadMore = () => { /* fetch next page */ };
// Pass a ref to the element we want to watch
const sentinel = ref(null);
useInfiniteScroll(sentinel, loadMore);
return { sentinel };
},
template: `
<div class="list">...</div>
<div ref="sentinel">Loading more...</div>
`
};
The “No-Go” Zone: Breaking the Rules
Composables feel like regular JavaScript, but they have “invisible walls”—especially if your app uses Server-Side Rendering (SSR).
1. Direct Browser Access (Top-Level)
The server doesn’t know what a “window” or “IntersectionObserver” is. If you try to create them at the top level of your function, the server will crash.
// ❌ CRASH: IntersectionObserver is undefined on the server
const observer = new IntersectionObserver(...);
// ✅ SAFE: Only runs once it hits the user's browser
onMounted(() => {
const observer = new IntersectionObserver(...);
});
2. Global State Leaks
While shared state is sometimes useful, it’s dangerous on a server. A global ref defined outside the function is shared across every user visiting your site.
// ❌ DANGEROUS: Every user shares the same "loading" state
const isLoading = ref(false);
export function useLoader() { ... }
// ✅ SAFE: Every component/user gets their own fresh state
export function useLoader() {
const isLoading = ref(false);
return { isLoading };
}
3. The Async Context Trap
Vue needs to track which component is calling a composable to handle things like onUnmounted. If you await a promise before registering a hook, Vue loses that connection.
// ❌ BROKEN: The cleanup logic will never trigger
export async function useAuth() {
const token = await fetchToken();
onUnmounted(() => { console.log('Cleaning up...') });
}
// ✅ SAFE: Register your hooks synchronously first
export function useAuth() {
onUnmounted(() => { console.log('Cleaning up...') });
// Now do your async logic...
}
Serialization: The Server-to-Client Handoff
When using a framework like Nuxt, your composables run on the server to generate HTML, then “hydrate” on the client. This transition requires Serialization—turning your variables into a JSON string to pass them over the network.
useState() vs ref()
A regular ref() doesn’t sync between the server and client. If the server generates a random number in a ref, the client will generate a different one when it takes over, causing a “Hydration Mismatch” error. Nuxt’s useState() acts as a safe container that survives this trip.
// ✅ Synchronized variable that survives the serialization tunnel
export const usePageNumber = () => {
return useState('page-number', () => 1);
};
The Serialization Limit
Remember that you can only serialize data, not behavior.
- Strings, Numbers, Objects: ✅ Serialized and synced perfectly.
- Functions, Classes: ❌ These cannot be sent as JSON. If your composable returns a function, that function exists on the server and separately on the client. The “connection” between them is lost.
Practical Patterns for Clean Logic
To keep your composables from becoming “mystery boxes,” follow these simple code patterns:
- Use
toValue()for Flexibility: This allows your composable to accept both raw values ('dark') and reactiverefs(themeRef). It automatically unwraps the value so you don’t have to write.valuechecks everywhere. - Return Objects, Not Arrays: React uses arrays (
[val, setVal]), but in Vue, we return objects. This lets you destructure only what you need (e.g.,const { isIntersecting } = useInfiniteScroll()) without worrying about the order of variables. - Don’t Skip the Cleanup: If your composable sets an interval or an event listener, you are responsible for killing it. Use
onUnmountedinside the composable to prevent memory leaks that will eventually lag the browser or crash your server.
By moving your logic out of the component and into these self-contained functions, you aren’t just cleaning up your script tags—you’re building a library of tools that actually scale with your app.