Composition API patterns I actually reach for
The handful of composables that keep large Vue apps maintainable without the ceremony.
Most Composition API writeups reach for the same three examples — a counter, a mouse tracker, a fetch wrapper — and then leave you to figure out how it holds up in a real app. Here’s what I actually reach for.
Key Takeaways
- Composables are just functions; make them boring (one job, small return shape)
- Co-locate state with the component tree that owns it; only reach for global stores when two unrelated parts genuinely need the same value
- Extract reusable composables at the point where you write the same pattern twice
- Unit test composables in isolation like any other function — the Composition API stops feeling like magic
Keep composables boring
A composable that does one thing and returns a small, predictable shape is easier to trust than one that tries to be clever. useFetch, usePagination, useForm — none of them need to be exciting.
Bad composable (too clever):
// Don't do this
export const useMagic = () => {
const state = ref({});
const cache = new Map();
const pending = ref(false);
const errors = ref([]);
const fetchWithRetry = async (url, options = {}) => {
// 50 lines of retry logic, caching, error handling, validation
// Returns: state, cache, pending, errors, fetch, retry, clear, validate...
};
return { state, cache, pending, errors, fetchWithRetry, /* 8 more things */ };
};
// Component usage: which return values do I actually need?
const { state, fetchWithRetry } = useMagic();
Good composable (focused):
// Do this instead
export const useFetch = (url) => {
const data = ref(null);
const error = ref(null);
const pending = ref(false);
const fetch = async () => {
pending.value = true;
error.value = null;
try {
const res = await window.fetch(url);
data.value = await res.json();
} catch (e) {
error.value = e;
} finally {
pending.value = false;
}
};
onMounted(fetch);
return { data, error, pending, fetch };
};
// Component usage: clear what you're getting
const { data, error, pending, fetch } = useFetch('/api/users');
Each return value is clear. Testing is straightforward: call the composable, check the return, assert the behavior. No surprises.
Co-locate state with the feature that owns it
Global stores are tempting, but most state belongs next to the component tree that uses it. Reach for a store only when two unrelated parts of the app genuinely need to agree on the same value.
Example: a user object. If only the user profile page reads and updates the user, keep it local:
<!-- UserProfile.vue -->
<template>
<div v-if="user">
<h1>{{ user.name }}</h1>
<button @click="updateUser">Save</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const user = ref(null);
const updateUser = async () => {
await api.patch(`/users/${user.value.id}`, user.value);
};
onMounted(async () => {
user.value = await api.get('/me');
});
</script>
If the navbar, sidebar, and footer all need the current user, that’s when a store makes sense:
// stores/user.js (Pinia)
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', () => {
const user = ref(null);
const fetchUser = async () => {
user.value = await api.get('/me');
};
const updateUser = async (updates) => {
await api.patch(`/users/${user.value.id}`, updates);
Object.assign(user.value, updates);
};
return { user, fetchUser, updateUser };
});
Components subscribe:
<template>
<header v-if="userStore.user">
<p>Hello, {{ userStore.user.name }}</p>
</header>
</template>
<script setup>
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
</script>
The rule: local state until it’s shared, then move it to a store. Don’t pre-optimize.
Extract composables at the second use, not the first
Writing the same pattern twice is a signal to extract. Writing it once is premature optimization.
Example: form handling. First form, just use local state:
<!-- ContactForm.vue -->
<template>
<form @submit.prevent="submit">
<input v-model="name" placeholder="Name" />
<input v-model="email" placeholder="Email" />
<button :disabled="pending">Send</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</template>
<script setup>
const name = ref('');
const email = ref('');
const pending = ref(false);
const error = ref(null);
const submit = async () => {
pending.value = true;
error.value = null;
try {
await api.post('/contact', { name, email });
name.value = '';
email.value = '';
} catch (e) {
error.value = e.message;
} finally {
pending.value = false;
}
};
</script>
Second form with the exact same pattern? Extract it:
// composables/useForm.js
export const useForm = (initialData, onSubmit) => {
const data = reactive({ ...initialData });
const pending = ref(false);
const error = ref(null);
const submit = async () => {
pending.value = true;
error.value = null;
try {
await onSubmit(data);
Object.assign(data, initialData);
} catch (e) {
error.value = e.message;
} finally {
pending.value = false;
}
};
const reset = () => Object.assign(data, initialData);
return { data, pending, error, submit, reset };
};
Use it:
<!-- ContactForm.vue and LoginForm.vue both use this -->
<script setup>
const { data, pending, error, submit } = useForm(
{ name: '', email: '' },
(formData) => api.post('/contact', formData)
);
</script>
At two uses, you see the pattern. At one use, you’re guessing.
Structure composables for testing
Because composables are functions, test them like functions:
// composables/useFetch.test.js
import { useF fetch } from './useFetch';
describe('useFetch', () => {
it('fetches data on mount', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({ id: 1, name: 'Test' })
});
const { data, pending, fetch } = useFetch('/api/data');
expect(pending.value).toBe(true);
await fetch();
expect(data.value).toEqual({ id: 1, name: 'Test' });
});
it('handles errors', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const { error, fetch } = useFetch('/api/data');
await fetch();
expect(error.value.message).toBe('Network error');
});
});
No component framework needed. Just call the composable, set up dependencies (mocked fetch), and assert the output.
This clarity is what the Composition API gives you: functions as functions. Testable, reusable, and boring by design.
Takeaways
Composables are just functions. Treat them like any other piece of code you’d want to unit test in isolation. Keep them focused (one job, small return shape), co-locate state with the component tree that owns it, extract reusable patterns at the second use, and test them as plain functions. Do that and the Composition API stops feeling like magic — it’s just a cleaner way to organize code.