Composable Abstraction Layer: the missing pattern between Pinia and its Vue components
Table of contents
- The problem
- What starts to go wrong
- The motivation
- The solution: Composable Abstraction Layer
- Architecture
- There is only one rule
- Step-by-step implementation
- Step 1: Store as pure state
- Step 2: Composable as an abstraction layer
- Step 3: Component consumes only the composable
- Patterns that emerge
- Pattern 1 — Read Composable (Query)
- Pattern 2 — Action Composable (Mutation)
- Pattern 3 — Legacy Store Wrapper
- Pattern 4 — Composable Utility
- File organization
- Naming conventions
- Testability
- Testing composable (unit test)
- Testing the component (component test)
- Enforcement: how to ensure adoption
- 1. Code review with checklist
- 2. Automated audit
- 3. Scaffolding that is already born in the pattern
- 4. Testing as a barrier
- When NOT to use
- Comparison: before and after
- Before (direct access)
- After (CAL)
- Summary
In recent years, I’ve worked with frontend architectures at different scales — from small projects with a handful of components to large applications, with multiple squads contributing to the same repository. I went through Vuex phases, migrated to Pinia, tried patterns like Repository Pattern adapted for the frontend, created too many abstractions, too few abstractions, and struggled with all the possible combinations.
What I’m going to present here is not an idea that came from an article or a spec. It’s a pattern that emerged from practice — from seeing the same problems repeat themselves in Vue 3 + Nuxt projects as they grew, and from tweaking the architecture until arriving at something that actually works on a day-to-day basis. I call it Composable Abstraction Layer (CAL).
The idea is simple: insert a layer of composables between your Pinia stores and your Vue components, eliminating coupling, centralizing logic and making each layer testable independently. It seems obvious when you read it, but most projects I find don’t do this — and pay the price as they scale.
The problem
I’m going to use a scenario that everyone who works in e-commerce knows: a product page. The component needs to fetch data, store it, display loading, handle errors and expose actions. The most intuitive approach with Pinia:
<script setup lang="ts">
const store = useProductStore();
await store.fetchProduct(route.params.slug);
const product = computed(() => store.product);
const loading = computed(() => store.loading);
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else>{{ product.name }}</div>
</template>
It seems reasonable. And it works — I’ve written a lot of code like this myself. But now multiply this by 40 components, 8 stores and 3 squads contributing to the same repository. That’s when things start to fall apart.
What starts to go wrong
1. Structural coupling
Every component knows the store’s internal API: state names, getters, actions. Renaming store.product to store.currentProduct requires changing dozens of .vue files. I’ve seen simple refactors turn into monstrous PRs because of this.
2. Scattered fetch logic
Some components call store.fetchProduct(). Others assume that the store is already populated. Others double down on the “just to be safe” call. There is no single point that orchestrates fetch and state synchronization.
<!-- ComponentA.vue -->
<script setup>
const store = useProductStore();
await store.fetchProduct(slug); // performs fetch
</script>
<!-- ComponentB.vue -->
<script setup>
const store = useProductStore();
// assumes fetch already happened — but did it?
const name = store.productName;
</script>
This kind of inconsistency is silent. It works in dev, it works in the happy path, and it breaks in production when the assembly order of components changes.
3. Tests coupled to Pinia
To test any component, you need to configure Pinia, create the store, populate the initial state and mock actions. Testing a simple component setup becomes a ritual:
// Coupled test setup
const pinia = createPinia();
setActivePinia(pinia);
const store = useProductStore();
store.$patch({ product: mockProduct, loading: false });
const wrapper = mount(ProductCard, {
global: { plugins: [pinia] },
props: { slug: 'racao-premium' },
});
When the test setup is longer than the test itself, something is wrong.
4. Ownerless side-effects
Error tracking, analytics, notifications — everything ends up in components. Each dev solves it in a different way, in a different place. I’ve spent hours tracking down why an analytics event would fire twice — it was because two different components had the same logic copied.
5. Fragile reactivity
Destructuring directly from the store loses reactivity. Devs need to remember to use storeToRefs — and inevitably forget:
// Silently loses reactivity
const { product, loading } = useProductStore();
// Works, but requires Pinia-specific knowledge
const { product, loading } = storeToRefs(useProductStore());
This is the type of bug that doesn’t appear in testing, doesn’t appear in dev with static data, and appears in production when the user navigates between pages and the state changes.
The result is a codebase where the UI layer knows too much about state management, and any change to the store has an unpredictable blast radius.
The motivation
When Vue 3 brought the Composition API and with it composables — functions that encapsulate reactive, reusable logic — the ecosystem quickly adopted them for UI logic (useMediaQuery, useDebounceFn), but most projects stopped there.
The question that led me to CAL was simple:
If composables are already the standard for abstracting reactive logic, why is the state layer — the most critical part of the application — still accessed directly?
The answer is that it doesn’t have to be. Composables can serve as an API contract between the application state and the UI, exactly as a controller serves as a contract between the model and the view in MVC architectures. The difference is that, in Vue 3, the tool is already in the language — it doesn’t need an additional framework.
After testing variations of this pattern on projects of different complexity, I came up with five principles that guide implementation:
- Encapsulation: components do not know that Pinia exists
- Separation of responsibilities: Store (state) → Composable (logic) → Component (UI)
- Stable contract: composable defines the public API; internal implementation may change
- Layered testability: each layer can be tested in isolation
- Guaranteed reactivity: composables return
computed()— no destructuring traps
The solution: Composable Abstraction Layer
Architecture
┌──────────────────────────────────────┐
│ COMPONENTS (UI) │ ← Only consume composables
│ ┌──────────────────────────────────┐│
│ │ COMPOSABLES (LOGIC) ││ ← Consume stores + APIs, expose clean API
│ │ ┌──────────────────────────────┐││
│ │ │ STORES (STATE) │││ ← Pure reactive state + getters
│ │ └──────────────────────────────┘││
│ └──────────────────────────────────┘│
└──────────────────────────────────────┘
There is only one rule
.vuefiles (pages and components) never calluseXxxStore(). Every state reading and every action goes through composables.
// ❌ Forbidden in .vue
const store = useProductStore();
const product = store.product;
// ✅ Allowed in .vue
const { product, loading, error } = useProduct(slug);
Composables and infrastructure code (middleware, plugins, server routes) can access stores directly — they are the layer that consumes the store.
Does it seem restrictive? AND. And it is precisely this restriction that gives the pattern its power. When all access to state passes through a funnel, you gain a single point of control, caching, logging, transformation, and evolution.
Step-by-step implementation
I will show you how to implement CAL from scratch using the product page scenario. The same structure applies to any feature.
Step 1: Store as pure state
The store doesn’t fetch, it doesn’t have side-effects. It is a reactive state container with computed getters. Think of it as a “local database” — it stores data and answers questions about it.
// store/product.ts
import type { Product, ReviewSummary } from '@/types/api';
interface ProductState {
product: Product | null;
reviewSummary: ReviewSummary | null;
}
export const useProductStore = defineStore('product', {
state: (): ProductState => ({
product: null,
reviewSummary: null,
}),
getters: {
hasProduct: (state) => !!state.product,
productName: (state) => state.product?.name ?? '',
averageRating: (state) => state.reviewSummary?.ratingAvg ?? 0,
totalReviews: (state) => state.reviewSummary?.reviewsCount ?? 0,
},
});
The store doesn’t know where the data comes from. It doesn’t import $fetch, it doesn’t call APIs, it doesn’t trigger notifications. This is on purpose.
Step 2: Composable as an abstraction layer
The composable is where everything happens: fetch, transformation, synchronization with the store and construction of the public API. It is the “backend-for-frontend” of your feature.
// composables/useProduct.ts
import type { ProductResponse } from '@/types/api';
export function useProduct(slug: string) {
const store = useProductStore();
const { data, error, pending, refresh } = useLazyFetch<ProductResponse>(
'/api/products',
{
key: `product-${slug}`,
method: 'POST',
body: { slug },
transform: (response: ProductResponse) => {
store.$patch((state) => {
state.product = response.product ?? null;
state.reviewSummary = response.reviewSummary ?? null;
});
return response;
},
}
);
return {
// Data — always computed() to guarantee reactivity
product: computed(() => store.product ?? data.value?.product),
reviewSummary: computed(
() => store.reviewSummary ?? data.value?.reviewSummary
),
// Request state
error,
pending,
refresh,
// Derived getters
hasProduct: computed(() => store.hasProduct),
productName: computed(() => store.productName),
averageRating: computed(() => store.averageRating),
};
}
Some details worth highlighting:
$patchwith callback:store.$patch((state) => { ... })groups multiple updates into a single reactivity notification. The form with a literal object (store.$patch({ key: value })) does not have the same gain and is less type-safe. It seems like a detail, but in stores with many fields, it makes a noticeable difference.computed()in everything that is reactive: the component receives computed refs that automatically track dependencies — without the risk of losing reactivity. This is one of the things I like most about the pattern: it eliminates an entire class of bugs.- Fallback
store.x ?? data.value?.x: guarantees that the data is available both via store (if it was populated by another composable or by SSR) and via direct fetch.
Step 3: Component consumes only the composable
<!-- pages/product/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const slug = route.params.slug as string;
const { product, pending, error, averageRating } = useProduct(slug);
</script>
<template>
<div v-if="pending" class="product-skeleton">Loading...</div>
<div v-else-if="error" class="product-error">
Could not load the product.
</div>
<div v-else-if="product" class="product-page">
<h1 class="product-page__title">{{ product.name }}</h1>
<ProductRating :average="averageRating" />
</div>
</template>
The component does not know that Pinia exists. It doesn’t import stores, it doesn’t call $patch, it doesn’t fetch. It receives reactive data and renders it. This is how it should be.
Patterns that emerge
As CAL matures in a project, recurring patterns become entrenched. In my experience, four have proven stable enough to be considered “canonical”.
Pattern 1 — Read Composable (Query)
The most common. Fetch, populate the store, expose reactive data. It’s the useProduct that I showed above, but it works for any feature that needs to fetch data:
export async function useFavorites() {
const store = useFavoriteStore();
const authStore = useAuthStore();
const userId = computed(() => authStore.session?.id ?? '');
const { data, error, refresh } = await useAsyncData('favorites', () =>
fetchFavorites(userId.value)
);
watch(
() => data.value?.items,
(items) => {
if (items) {
store.$patch((state) => {
state.items = items;
});
}
},
{ immediate: true }
);
return {
favorites: computed(() => store.items),
hasFavorites: computed(() => store.items.length > 0),
isEmpty: computed(() => store.items.length === 0),
refresh,
error,
};
}
Note that composable consumes another store (useAuthStore) to obtain userId. This is perfectly valid — composables are the layer that is allowed to access stores. The component never needs to know that authentication and favorites are related internally.
Pattern 2 — Action Composable (Mutation)
Encapsulates a write operation. Manages pending and error internally. You don’t need a store if the state is ephemeral — and in most cases of mutations, it is.
export function useRemoveFromFavorites() {
const pending = ref(false);
const error = ref<Error | null>(null);
async function remove(productIds: string[], onSuccess?: () => Promise<void>) {
pending.value = true;
error.value = null;
try {
await $fetch('/api/favorites/remove', {
method: 'DELETE',
body: { productIds },
});
await onSuccess?.();
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
throw e;
} finally {
pending.value = false;
}
}
return { remove, pending, error };
}
The interesting detail here is the onSuccess callback. Instead of the action composable knowing how to update the favorites list (which would create coupling between composables), it accepts a generic callback. In practice, the page passes the refresh of the reading composable:
<script setup lang="ts">
const { favorites, refresh } = await useFavorites();
const { remove, pending: removing } = useRemoveFromFavorites();
async function handleRemove(productId: string) {
await remove([productId], refresh);
}
</script>
This composition is clean and explicit. Each composable has a responsibility, and the component orchestrates the interaction between them.
Pattern 3 — Legacy Store Wrapper
This is the pattern that saved me the most in migrations. When the project depends on stores that came from an external package or a legacy module, composable acts as an adapter:
// Store comes from an external package — API is not under our control
// import { useSessionStore } from '@acme/auth-module'
export function useAuth() {
const store = useSessionStore();
return {
isLoggedIn: computed(() => store.isLogged),
user: computed(() => store.getSession),
isSubscriber: computed(() => store.isClubMember ?? false),
};
}
This is powerful for two reasons:
- If the external package changes the store API (and it happens), only the composable needs to be updated. I’ve already gone through a major version update of an auth module where 100% of the migration was contained in a single composable file. Zero changes to components.
- We can normalize naming — the component never needs to know that internally the getter is called
getSessionorisLogged. The API that the UI consumes is the one that makes sense for the UI.
Pattern 4 — Composable Utility
Functions that use store internally but expose a high-level semantic API. The most classic example are notifications:
export function useSuccessNotification(message: string) {
useNotificationStore().show({
message,
type: 'positive',
});
}
export function useErrorNotification(error: unknown, fallbackMessage?: string) {
const normalized = error instanceof Error ? error : new Error(String(error));
const message = fallbackMessage ?? extractMessage(normalized);
useNotificationStore().show({
message,
type: 'negative',
});
// Centralizes error tracking
useNuxtApp().$errorTracker?.notify(normalized, {
context: { displayMessage: message },
});
}
The component doesn’t know how notifications work internally. It doesn’t know that there is a store behind it, nor that errors are sent to a tracking service:
try {
await remove([product.id], refresh);
useSuccessNotification('Product removed from favorites');
} catch (error) {
useErrorNotification(error, 'Failed to remove. Please try again.');
}
This type of composable utility is where CAL shines for centralizing side-effects that were previously copied across dozens of components.
File organization
CAL fits naturally into Nuxt projects organized by feature (layers). If you’re not already familiar with Nuxt Layers, the post Modular Architecture with Nuxt Layers in Vue Projects is a good starting point — the structure below makes even more sense in this context.
layers/
├── favorites/
│ ├── store/
│ │ └── favorites.ts # Pure state + getters
│ ├── composables/
│ │ ├── useFavorites.ts # Main query
│ │ ├── useAddToFavorites.ts # Mutation: add
│ │ └── useRemoveFromFavorites.ts # Mutation: remove
│ ├── components/
│ │ ├── FavoriteCard.vue # Consumes via props (from page)
│ │ └── FavoriteCard.test.ts
│ ├── pages/
│ │ └── favorites/
│ │ └── index.vue # Consome composables
│ └── types/
│ └── favorites.ts # Derived types
├── product/
│ ├── store/
│ │ └── product.ts
│ ├── composables/
│ │ ├── useProduct.ts
│ │ ├── useReview.ts
│ │ └── useQuestion.ts
│ └── ...
└── shared/
└── composables/
├── useAuth.ts # Wrapper de store legada
├── useCart.ts # Wrapper de store legada
└── useNotification.ts # Utilities
Naming conventions
| Artifact | Standard | Example |
|---|---|---|
| Store | use[Feature]Store | useFavoriteStore |
| Main Composable | use[Feature] | useFavorites |
| Action Composable | use[Feature]Action | useRemoveFromFavorites |
| Filter Composable | use[Feature]Filters | useProductFilters |
One thing I learned: keeping the composable and its test in the same directory (colocation) makes all the difference. When the test is next to the file, the barrier to writing it decreases. When it’s in a distant __tests__ folder, no one remembers it exists.
Testability
This is honestly the advantage that most convinced me to adopt CAL consistently. Each layer is testable in isolation, and the setup for each test is proportionally simple.
Testing composable (unit test)
import { describe, it, expect, vi } from 'vitest';
// Fetch mock — single integration point
vi.mock('#app', () => ({
useLazyFetch: vi.fn(() => ({
data: ref({ product: { name: 'Premium Pet Food', slug: 'racao-premium' } }),
error: ref(null),
pending: ref(false),
refresh: vi.fn(),
})),
}));
describe('useProduct', () => {
it('should return product data when fetch succeeds', () => {
const { product, hasProduct, pending } = useProduct('racao-premium');
expect(product.value).toEqual({
name: 'Premium Pet Food',
slug: 'racao-premium',
});
expect(hasProduct.value).toBe(true);
expect(pending.value).toBe(false);
});
it('should expose error when fetch fails', () => {
vi.mocked(useLazyFetch).mockReturnValueOnce({
data: ref(null),
error: ref(new Error('Network error')),
pending: ref(false),
refresh: vi.fn(),
});
const { error, hasProduct } = useProduct('invalid-slug');
expect(error.value).toBeTruthy();
expect(hasProduct.value).toBe(false);
});
});
Testing the component (component test)
This is where the magic appears. The component doesn’t know that Pinia exists — just mock the composable:
import { mountSuspended } from '@nuxt/test-utils/runtime';
// Composable mock — store is not part of the equation
vi.mock('@/composables/useProduct', () => ({
useProduct: vi.fn(() => ({
product: computed(() => ({ name: 'Premium Pet Food' })),
pending: ref(false),
error: ref(null),
hasProduct: computed(() => true),
averageRating: computed(() => 4.5),
})),
}));
describe('ProductPage', () => {
it('should render product name', async () => {
const wrapper = await mountSuspended(ProductPage, {
route: { params: { slug: 'racao-premium' } },
});
expect(wrapper.text()).toContain('Premium Pet Food');
});
it('should render loading state', async () => {
vi.mocked(useProduct).mockReturnValueOnce({
product: computed(() => null),
pending: ref(true),
error: ref(null),
hasProduct: computed(() => false),
averageRating: computed(() => 0),
});
const wrapper = await mountSuspended(ProductPage, {
route: { params: { slug: 'racao-premium' } },
});
expect(wrapper.text()).toContain('Loading');
});
});
Compare this with the setup I showed at the beginning of the article, where it was necessary to configure Pinia, create store, popular status. With CAL, the component test mocks a function that returns refs — the same contract that the component already uses in production. It’s straightforward, it’s predictable, and it encourages the team to write more tests.
Enforcement: how to ensure adoption
Documenting the standard is not enough — I learned that the hard way. Without active enforcement, architectural erosion is inevitable. All it takes is for a dev in a hurry to access the store directly “just this once” and, two weeks later, half the team is doing the same. These are the strategies that worked for me:
1. Code review with checklist
Include in the PR checklist:
- No file
.vueimportsuseXxxStore() - Composables use
$patchwith callback (not object literal) - Reactive data exposed via
computed()
2. Automated audit
An audit script that greps for violations:
# Search for store imports in .vue files
grep -rn "useXxxStore\|use.*Store()" --include="*.vue" layers/
# Search for $patch with object literal (without callback)
grep -rn '\$patch({' --include="*.ts" layers/
It may seem rudimentary, but it’s surprisingly effective. In a project where I applied this, we ran the script in CI and blocked merge when it found violations. In three months, the entire team internalized the pattern.
3. Scaffolding that is already born in the pattern
Code generation templates that create store + composable + component already following the CAL. Devs don’t need to remember the pattern — the boilerplate is already correct. This drastically reduces adoption friction, especially for those new to the project.
4. Testing as a barrier
If components only test via mocked composables, any attempt to access store directly in the component will fail the test — because the store is not configured. Testing becomes a natural barrier against breaches.
When NOT to use
It would be dishonest of me to present CAL as a silver bullet. It adds a layer of indirection, and that comes at a cost. Throughout the projects I applied it to, I refined my understanding of where it makes sense and where overhead is:
- Small projects (< 5 stores, 1-2 devs): the overhead of maintaining intermediate composables may not be worth it. If the entire team is you, coupling is manageable.
- Prototypes and MVPs: speed matters more than architecture. Accessing the store directly is faster to write. You can always refactor later — if the project survives.
- Purely local state: if the state only exists within a component (
ref()local), it does not need a store or composable. Don’t create abstractions for state that no one else consumes. - Composable that only passes: if the composable does not add logic (no fetch, no transformation, no side-effects) and only passes
storeToRefs, evaluate whether the indirection is justified. Sometimes a straightstoreToRefsis honestly the best option.
The rule of thumb I use: adopt CAL when multiple components consume the same store or when the state synchronization logic is non-trivial. In my experience, in any project that tends to scale — whether in features, in devs or in state complexity — this threshold is reached quickly.
Comparison: before and after
Before (direct access)
ComponentA.vue ──→ useProductStore()
ComponentB.vue ──→ useProductStore()
ComponentC.vue ──→ useProductStore()
PageX.vue ─────────→ useProductStore() + fetch + error handling
PageY.vue ─────────→ useProductStore() + fetch + error handling (duplicated)
- 5 docking points with the store
- Duplicate fetch logic
- Testing each component requires Pinia setup
After (CAL)
ComponentA.vue ──→ props (data comes from page)
ComponentB.vue ──→ props
ComponentC.vue ──→ useProduct()
PageX.vue ─────────→ useProduct()
PageY.vue ─────────→ useProduct()
│
useProduct() ──→ useProductStore()
- 1 coupling point with the store (composable)
- Centralized fetch logic
- Testable components with simple mock
Summary
| Layer | Responsibility | Access store? | Access composable? |
|---|---|---|---|
| Store | Reactive state + pure getters | — | No |
| Composable | Fetch, logic, side-effects, public API | Yes | You can compose others |
| Component | Rendering + user interaction | Never | Yes |
| Infrastructure (middleware, plugin) | Setup, guards, config | Yes | You can use both |
The Composable Abstraction Layer is not a framework, it is not a lib, it does not require dependencies. It’s an architectural pattern — a convention for where to place each type of logic in the Vue 3 + Pinia ecosystem. The Composition API already gave us the tool. CAL is just the discipline of using it as an abstraction layer, and not just as a repository of utility functions.
I arrived at this pattern after years of experimenting, making mistakes and refining. It’s not perfect, it’s not for every project, but in applications that tend to scale — in features, in devs, in complexity — this discipline is the difference between a codebase that scales together and one that just survives.
If you’re in a growing Vue 3 + Pinia project and feel like the state is becoming a mess, try applying CAL to a new feature. You don’t need to refactor everything at once. Start with a store, create the composable, adjust the components. When the team feels the difference in testing and refactoring, adoption happens naturally.