The Composition API was Vue 3's biggest shift. Tutorials cover the basics — ref, computed, <script setup> — and then leave you to figure out the rest. This post is the patterns that show up in production Vue 3 applications: composables that scale, provide/inject without surprises, testing strategies, and the anti-patterns that are easy to hit.
When to Reach for a Composable
A composable is a function that uses Vue's reactivity primitives and returns reactive state and methods. The conventional name starts with use.
The mistake people make: turning every reusable function into a composable. Composables only make sense when they hold reactive state or side effects. A pure utility (date formatting, string manipulation) is just a function.
// Pure function — not a composable
export function formatCurrency(cents: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency })
.format(cents / 100);
}
// Composable — owns reactive state
export function useDebouncedValue<T>(source: Ref<T>, ms = 300) {
const debounced = ref(source.value);
let timeoutId: ReturnType<typeof setTimeout>;
watch(source, (value) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => { debounced.value = value; }, ms);
});
return { debounced };
}
The composable signature pattern: take parameters, return an object with refs and methods.
State Management Without Pinia
For simple shared state, a module-level ref with a function that returns it is enough.
// src/state/notifications.ts
const notifications = ref<Notification[]>([]);
export function useNotifications() {
return {
notifications: readonly(notifications),
add(n: Notification) { notifications.value.push(n); },
remove(id: string) {
notifications.value = notifications.value.filter(n => n.id !== id);
},
};
}
Every component that calls useNotifications() gets the same shared array. This is fine for small applications. For applications with many shared state slices, Pinia is the right next step — it adds dev tools, plugin support, and structure.
The temptation is to reach for Pinia on day one. For most components, the composable pattern is enough.
Provide and Inject
provide and inject pass values down through the component tree without prop drilling. Useful for things every nested component needs — the current theme, the API client, the user.
// In a top-level component
import { provide, inject, InjectionKey } from 'vue';
export const ApiClientKey: InjectionKey<ApiClient> = Symbol('ApiClient');
provide(ApiClientKey, new ApiClient(import.meta.env.VITE_API_URL));
// In any descendant
const api = inject(ApiClientKey);
if (!api) throw new Error('ApiClient not provided');
Two practices that prevent runtime bugs:
- Always use
InjectionKey<T>symbols, not strings. TypeScript infers the inject return type from the key. - Always check for undefined.
injectreturnsundefinedif the provider is missing. Either provide a default or throw.
Watch and WatchEffect
watch watches specific sources and runs a callback when they change. watchEffect runs a function immediately and re-runs it when its dependencies change.
Most production code uses watch. watchEffect is convenient but its dependency tracking can be surprising — it tracks whatever is accessed during the run, which can lead to over-watching.
// watch — explicit
watch(searchQuery, (q) => loadResults(q));
// watchEffect — implicit
watchEffect(() => {
loadResults(searchQuery.value);
// If you access searchQuery.value AND filters.value here,
// both are dependencies — even if you only meant searchQuery.
});
Prefer watch when you want a specific source. Use watchEffect for setup-once-and-react-to-everything patterns.
Async in Setup
The <script setup> block does not support top-level await directly, but you can use it inside async composables:
// Inside a composable
export async function useProfile() {
const profile = ref<Profile | null>(null);
profile.value = await api.get('/me');
return { profile };
}
// In <script setup>
const { profile } = await useProfile();
Top-level await in <script setup> requires the parent component to use <Suspense>. If you are not using Suspense, return a loading state instead of awaiting:
export function useProfile() {
const profile = ref<Profile | null>(null);
const loading = ref(true);
api.get('/me').then(p => {
profile.value = p;
loading.value = false;
});
return { profile, loading };
}
Testing Composables
Composables can be unit-tested without rendering a component, but only in a way that respects Vue's reactivity. The pattern is to call the composable inside a small test component.
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useDebouncedValue } from './useDebouncedValue';
function withSetup<T>(composable: () => T): { result: T; app: any } {
let result!: T;
const app = mount(defineComponent({
setup() {
result = composable();
return () => null;
},
}));
return { result, app };
}
test('debounces values', async () => {
const { result } = withSetup(() => {
const source = ref('initial');
const { debounced } = useDebouncedValue(source, 100);
return { source, debounced };
});
result.source.value = 'changed';
expect(result.debounced.value).toBe('initial');
await new Promise(r => setTimeout(r, 150));
expect(result.debounced.value).toBe('changed');
});
This pattern preserves reactivity. Calling a composable outside of a Vue setup context loses the lifecycle hooks.
Anti-Patterns to Avoid
Mutating refs from outside. If a composable returns { count }, the caller can do count.value++. Use readonly() to prevent this:
return { count: readonly(count), increment };
Composables that read from globals. A composable that reads localStorage or window.location directly is hard to test. Inject those dependencies:
export function useLocalStorage(storage: Storage = window.localStorage) { ... }
Holding too much state in <script setup>. A 400-line <script setup> is the same problem as a 400-line class. Extract to composables.
Reaching across components via emits. Heavy use of emit to pass state up and back down is a sign you need a shared composable or Pinia store.
When to Use the Options API Anyway
Vue 3 still supports the Options API, and it is fine. Reach for it when:
- The component is small and the lifecycle hooks dominate (Composition API does not add much)
- The team is mid-migration from Vue 2 and Options API is more familiar
- The component's structure maps cleanly onto data/methods/computed
Composition API wins for complex components, reusable logic, and TypeScript. Options API wins for simple components and lower onboarding cost. Most production codebases end up with both; that is fine.
The Pattern That Scales
After a few production cycles, the structure that holds up:
- Components are thin. They render and dispatch.
- Composables hold logic. Anything reusable, anything reactive, anything testable.
- Pinia for cross-feature state. Stores have actions, getters, and persistence.
- Provide/inject for plumbing. API clients, theme tokens, user context.
The Composition API's value emerges as the app grows. Small apps look about the same in either API; large apps are dramatically clearer in Composition.
Reviewing a Vue codebase that has accumulated complexity and starting to feel hard to extend? We help teams refactor toward composables and structured state without rewriting the world. scopeforged.com