Composable Abstraction Layer: o pattern que faltava entre Pinia e seus componentes Vue
Sumário
- O problema
- O que começa a dar errado
- A motivação
- A solução: Composable Abstraction Layer
- Arquitetura
- A regra é uma só
- Implementação passo a passo
- Passo 1: Store como estado puro
- Passo 2: Composable como camada de abstração
- Passo 3: Componente consome apenas o composable
- Padrões que emergem
- Padrão 1 — Composable de Leitura (Query)
- Padrão 2 — Composable de Ação (Mutation)
- Padrão 3 — Wrapper de Store Legada
- Padrão 4 — Composable Utilitário
- Organização de arquivos
- Convenções de nomenclatura
- Testabilidade
- Testando o composable (unit test)
- Testando o componente (component test)
- Enforcement: como garantir a adoção
- 1. Code review com checklist
- 2. Auditoria automatizada
- 3. Scaffolding que já nasce no padrão
- 4. Testes como barreira
- Quando NÃO usar
- Comparação: antes e depois
- Antes (acesso direto)
- Depois (CAL)
- Resumo
Nos últimos anos, trabalhei com arquiteturas frontend em diferentes escalas — desde projetos pequenos com um punhado de componentes até aplicações grandes, com múltiplos squads contribuindo no mesmo repositório. Passei por fases de Vuex, migrei para Pinia, experimentei patterns como Repository Pattern adaptado para o frontend, criei abstrações demais, abstrações de menos, e quebrei a cara com todas as combinações possíveis.
O que vou apresentar aqui não é uma ideia que surgiu de um artigo ou de uma spec. É um padrão que emergiu da prática — de ver os mesmos problemas se repetirem em projetos Vue 3 + Nuxt conforme eles cresciam, e de ir ajustando a arquitetura até chegar em algo que realmente funciona no dia a dia. Chamo de Composable Abstraction Layer (CAL).
A ideia é simples: inserir uma camada de composables entre suas stores Pinia e seus componentes Vue, eliminando acoplamento, centralizando lógica e tornando cada camada testável de forma independente. Parece óbvio quando você lê, mas a maioria dos projetos que encontro por aí não faz isso — e paga o preço conforme escala.
O problema
Vou usar um cenário que todo mundo que trabalha com e-commerce conhece: uma página de produto. O componente precisa buscar dados, armazená-los, exibir loading, tratar erros e expor ações. A abordagem mais intuitiva com 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">Carregando...</div>
<div v-else>{{ product.name }}</div>
</template>
Parece razoável. E funciona — eu mesmo escrevi muito código assim. Mas agora multiplique isso por 40 componentes, 8 stores e 3 squads contribuindo no mesmo repositório. É aí que as coisas começam a desmoronar.
O que começa a dar errado
1. Acoplamento estrutural
Todo componente conhece a API interna da store: nomes de state, getters, actions. Renomear store.product para store.currentProduct exige alterar dezenas de arquivos .vue. Já vi refactors simples virarem PRs monstruosos por causa disso.
2. Lógica de fetch espalhada
Alguns componentes chamam store.fetchProduct(). Outros assumem que a store já está populada. Outros duplicam a chamada “por segurança”. Não há um ponto único que orquestre o fetch e a sincronização do estado.
<!-- ComponenteA.vue -->
<script setup>
const store = useProductStore();
await store.fetchProduct(slug); // faz o fetch
</script>
<!-- ComponenteB.vue -->
<script setup>
const store = useProductStore();
// assume que já foi feito fetch — mas será que foi?
const name = store.productName;
</script>
Esse tipo de inconsistência é silenciosa. Funciona em dev, funciona no caminho feliz, e quebra em produção quando a ordem de montagem dos componentes muda.
3. Testes acoplados ao Pinia
Para testar qualquer componente, é necessário configurar Pinia, criar a store, popular o estado inicial e mockar actions. O setup de teste de um componente simples vira um ritual:
// Setup de teste acoplado
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' },
});
Quando o setup do teste é mais longo que o teste em si, algo está errado.
4. Side-effects sem dono
Error tracking, analytics, notificações — tudo acaba nos componentes. Cada dev resolve de um jeito, em um lugar diferente. Já perdi horas rastreando por que um evento de analytics disparava duas vezes — era porque dois componentes diferentes tinham a mesma lógica copiada.
5. Reatividade frágil
Desestruturar diretamente da store perde reatividade. Devs precisam lembrar de usar storeToRefs — e inevitavelmente esquecem:
// Perde reatividade silenciosamente
const { product, loading } = useProductStore();
// Funciona, mas exige conhecimento específico do Pinia
const { product, loading } = storeToRefs(useProductStore());
Esse é o tipo de bug que não aparece em teste, não aparece em dev com dados estáticos, e aparece em produção quando o usuário navega entre páginas e o estado muda.
O resultado é um codebase onde a camada de UI sabe demais sobre gerenciamento de estado, e qualquer mudança na store tem blast radius imprevisível.
A motivação
Quando o Vue 3 trouxe a Composition API e com ela os composables — funções que encapsulam lógica reativa e reutilizável — o ecossistema rapidamente os adotou para lógica de UI (useMediaQuery, useDebounceFn), mas a maioria dos projetos parou aí.
A pergunta que me levou ao CAL foi simples:
Se composables já são o padrão para abstrair lógica reativa, por que a camada de estado — a parte mais crítica da aplicação — ainda é acessada diretamente?
A resposta é que não precisaria ser. Composables podem servir como contrato de API entre o estado da aplicação e a UI, exatamente como um controller serve de contrato entre o model e a view em arquiteturas MVC. A diferença é que, no Vue 3, a ferramenta já está na linguagem — não precisa de framework adicional.
Depois de testar variações desse pattern em projetos de complexidades diferentes, cheguei em cinco princípios que guiam a implementação:
- Encapsulamento: componentes não sabem que Pinia existe
- Separação de responsabilidades: Store (estado) → Composable (lógica) → Componente (UI)
- Contrato estável: o composable define a API pública; a implementação interna pode mudar
- Testabilidade em camadas: cada camada pode ser testada isoladamente
- Reatividade garantida: composables retornam
computed()— sem armadilhas de desestruturação
A solução: Composable Abstraction Layer
Arquitetura
┌──────────────────────────────────────┐
│ COMPONENTES (UI) │ ← Só consomem composables
│ ┌──────────────────────────────────┐│
│ │ COMPOSABLES (LÓGICA) ││ ← Consomem stores + APIs, expõem API limpa
│ │ ┌──────────────────────────────┐││
│ │ │ STORES (ESTADO) │││ ← Estado reativo puro + getters
│ │ └──────────────────────────────┘││
│ └──────────────────────────────────┘│
└──────────────────────────────────────┘
A regra é uma só
Arquivos
.vue(pages e components) nunca chamamuseXxxStore(). Toda leitura de estado e toda ação passam por composables.
// ❌ Proibido em .vue
const store = useProductStore();
const product = store.product;
// ✅ Permitido em .vue
const { product, loading, error } = useProduct(slug);
Composables e código de infraestrutura (middlewares, plugins, server routes) podem acessar stores diretamente — eles são a camada que consome a store.
Parece restritivo? É. E é justamente essa restrição que dá poder ao pattern. Quando todo acesso ao estado passa por um funil, você ganha um ponto único de controle, cache, logging, transformação e evolução.
Implementação passo a passo
Vou mostrar como implementar o CAL do zero usando o cenário de página de produto. A mesma estrutura se aplica a qualquer feature.
Passo 1: Store como estado puro
A store não faz fetch, não tem side-effects. Ela é um container de estado reativo com getters computados. Pense nela como um “banco de dados local” — ela guarda dados e responde perguntas sobre eles.
// 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,
},
});
A store não sabe de onde os dados vêm. Ela não importa $fetch, não chama APIs, não dispara notificações. Isso é proposital.
Passo 2: Composable como camada de abstração
O composable é onde tudo acontece: fetch, transformação, sincronização com a store e construção da API pública. Ele é o “backend-for-frontend” da sua 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 {
// Dados — sempre computed() para garantir reatividade
product: computed(() => store.product ?? data.value?.product),
reviewSummary: computed(
() => store.reviewSummary ?? data.value?.reviewSummary
),
// Estado da requisição
error,
pending,
refresh,
// Getters derivados
hasProduct: computed(() => store.hasProduct),
productName: computed(() => store.productName),
averageRating: computed(() => store.averageRating),
};
}
Alguns detalhes que valem destaque:
$patchcom callback:store.$patch((state) => { ... })agrupa múltiplas atualizações em uma única notificação de reatividade. A forma com objeto literal (store.$patch({ key: value })) não tem o mesmo ganho e é menos type-safe. Parece um detalhe, mas em stores com muitos campos, faz diferença perceptível.computed()em tudo que é reativo: o componente recebe refs computadas que rastreiam dependências automaticamente — sem risco de perder reatividade. Esse é um dos pontos que mais gosto no pattern: ele elimina uma classe inteira de bugs.- Fallback
store.x ?? data.value?.x: garante que o dado está disponível tanto via store (se foi populada por outro composable ou por SSR) quanto via fetch direto.
Passo 3: Componente consome apenas o 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">Carregando...</div>
<div v-else-if="error" class="product-error">
Não foi possível carregar o produto.
</div>
<div v-else-if="product" class="product-page">
<h1 class="product-page__title">{{ product.name }}</h1>
<ProductRating :average="averageRating" />
</div>
</template>
O componente não sabe que Pinia existe. Ele não importa stores, não chama $patch, não faz fetch. Ele recebe dados reativos e renderiza. É assim que deveria ser.
Padrões que emergem
Conforme o CAL amadurece em um projeto, padrões recorrentes se consolidam. Na minha experiência, quatro se mostraram estáveis o suficiente para serem considerados “canônicos”.
Padrão 1 — Composable de Leitura (Query)
O mais comum. Faz fetch, popula a store, expõe dados reativos. É o useProduct que mostrei acima, mas funciona para qualquer feature que precisa buscar dados:
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 que o composable consome outra store (useAuthStore) para obter o userId. Isso é perfeitamente válido — composables são a camada que tem permissão para acessar stores. O componente nunca precisa saber que autenticação e favoritos estão relacionados internamente.
Padrão 2 — Composable de Ação (Mutation)
Encapsula uma operação de escrita. Gerencia pending e error internamente. Não precisa de store se o estado é efêmero — e na maioria dos casos de mutations, ele é.
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 };
}
O detalhe interessante aqui é o onSuccess callback. Em vez de o composable de ação saber como atualizar a lista de favoritos (o que criaria acoplamento entre composables), ele aceita um callback genérico. Na prática, a page passa o refresh do composable de leitura:
<script setup lang="ts">
const { favorites, refresh } = await useFavorites();
const { remove, pending: removing } = useRemoveFromFavorites();
async function handleRemove(productId: string) {
await remove([productId], refresh);
}
</script>
Essa composição é limpa e explícita. Cada composable tem uma responsabilidade, e o componente orquestra a interação entre eles.
Padrão 3 — Wrapper de Store Legada
Esse é o padrão que mais me salvou em migrações. Quando o projeto depende de stores que vieram de um pacote externo ou de um módulo legado, o composable atua como adapter:
// Store vem de pacote externo — não controlamos a API
// 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),
};
}
Isso é poderoso por dois motivos:
- Se o pacote externo mudar a API da store (e isso acontece), apenas o composable precisa ser atualizado. Já passei por uma atualização de major version de um módulo de auth onde 100% da migração ficou contida em um único arquivo de composable. Zero mudanças em componentes.
- Podemos normalizar nomenclatura — o componente nunca precisa saber que internamente o getter se chama
getSessionouisLogged. A API que a UI consome é a que faz sentido para a UI.
Padrão 4 — Composable Utilitário
Funções que usam store internamente mas expõem uma API semântica de alto nível. O exemplo mais clássico são notificações:
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',
});
// Centraliza error tracking
useNuxtApp().$errorTracker?.notify(normalized, {
context: { displayMessage: message },
});
}
O componente não sabe como notificações funcionam internamente. Não sabe que existe um store por trás, nem que errors são enviados para um serviço de tracking:
try {
await remove([product.id], refresh);
useSuccessNotification('Produto removido dos favoritos');
} catch (error) {
useErrorNotification(error, 'Falha ao remover. Tente novamente.');
}
Esse tipo de composable utilitário é onde o CAL brilha para centralizar side-effects que antes ficavam copiados em dezenas de componentes.
Organização de arquivos
O CAL se encaixa naturalmente em projetos Nuxt organizados por feature (layers). Se você ainda não está familiarizado com Nuxt Layers, o post Arquitetura Modular com Nuxt Layers em Projetos Vue é um bom ponto de partida — a estrutura abaixo faz ainda mais sentido com esse contexto.
layers/
├── favorites/
│ ├── store/
│ │ └── favorites.ts # Estado puro + getters
│ ├── composables/
│ │ ├── useFavorites.ts # Query principal
│ │ ├── useAddToFavorites.ts # Mutation: adicionar
│ │ └── useRemoveFromFavorites.ts # Mutation: remover
│ ├── components/
│ │ ├── FavoriteCard.vue # Consome via props (vindo da page)
│ │ └── FavoriteCard.test.ts
│ ├── pages/
│ │ └── favorites/
│ │ └── index.vue # Consome composables
│ └── types/
│ └── favorites.ts # Types derivados
├── 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 # Utilitários
Convenções de nomenclatura
| Artefato | Padrão | Exemplo |
|---|---|---|
| Store | use[Feature]Store | useFavoriteStore |
| Composable principal | use[Feature] | useFavorites |
| Composable de ação | use[Feature]Action | useRemoveFromFavorites |
| Composable de filtro | use[Feature]Filters | useProductFilters |
Uma coisa que aprendi: manter o composable e seu teste no mesmo diretório (colocation) faz toda a diferença. Quando o teste está ao lado do arquivo, a barreira para escrevê-lo diminui. Quando está em uma pasta __tests__ distante, ninguém lembra que ele existe.
Testabilidade
Essa é, honestamente, a vantagem que mais me convenceu a adotar o CAL de forma consistente. Cada camada é testável de forma isolada, e o setup de cada teste é proporcionalmente simples.
Testando o composable (unit test)
import { describe, it, expect, vi } from 'vitest';
// Mock do fetch — único ponto de integração
vi.mock('#app', () => ({
useLazyFetch: vi.fn(() => ({
data: ref({ product: { name: 'Ração Premium', 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: 'Ração Premium',
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('slug-invalido');
expect(error.value).toBeTruthy();
expect(hasProduct.value).toBe(false);
});
});
Testando o componente (component test)
Aqui é onde a mágica aparece. O componente não sabe que Pinia existe — basta mockar o composable:
import { mountSuspended } from '@nuxt/test-utils/runtime';
// Mock do composable — a store nem entra na equação
vi.mock('@/composables/useProduct', () => ({
useProduct: vi.fn(() => ({
product: computed(() => ({ name: 'Ração Premium' })),
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('Ração Premium');
});
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('Carregando');
});
});
Compare isso com o setup que mostrei no início do artigo, onde era necessário configurar Pinia, criar store, popular estado. Com CAL, o teste do componente mocka uma função que retorna refs — o mesmo contrato que o componente já usa em produção. É direto, é previsível, e encoraja o time a escrever mais testes.
Enforcement: como garantir a adoção
Documentar o padrão não é suficiente — aprendi isso da maneira difícil. Sem enforcement ativo, a erosão arquitetural é inevitável. Basta um dev com pressa acessar a store direto “só dessa vez” e, duas semanas depois, metade do time está fazendo o mesmo. Estas são as estratégias que funcionaram para mim:
1. Code review com checklist
Incluir na checklist de PR:
- Nenhum arquivo
.vueimportauseXxxStore() - Composables usam
$patchcom callback (não object literal) - Dados reativos expostos via
computed()
2. Auditoria automatizada
Um script de auditoria que faz grep por violações:
# Busca imports de store em arquivos .vue
grep -rn "useXxxStore\|use.*Store()" --include="*.vue" layers/
# Busca $patch com object literal (sem callback)
grep -rn '\$patch({' --include="*.ts" layers/
Pode parecer rudimentar, mas é surpreendentemente eficaz. Em um projeto onde apliquei isso, rodamos o script no CI e bloqueamos merge quando encontrava violações. Em três meses, o time inteiro internalizou o pattern.
3. Scaffolding que já nasce no padrão
Templates de geração de código que criam store + composable + componente já seguindo o CAL. Devs não precisam lembrar do padrão — o boilerplate já está correto. Isso reduz fricção de adoção drasticamente, principalmente para quem está chegando no projeto.
4. Testes como barreira
Se componentes só testam via composables mockados, qualquer tentativa de acessar store diretamente no componente vai falhar no teste — porque a store não está configurada. Os testes viram uma barreira natural contra violações.
Quando NÃO usar
Seria desonesto da minha parte apresentar o CAL como bala de prata. Ele adiciona uma camada de indireção, e isso tem custo. Ao longo dos projetos em que apliquei, fui refinando o entendimento de onde ele faz sentido e onde é overhead:
- Projetos pequenos (< 5 stores, 1-2 devs): o overhead de manter composables intermediários pode não compensar. Se todo o time é você, o acoplamento é gerenciável.
- Protótipos e MVPs: velocidade importa mais que arquitetura. Acessar store direto é mais rápido de escrever. Você sempre pode refatorar depois — se o projeto sobreviver.
- Estado puramente local: se o estado só existe dentro de um componente (
ref()local), não precisa de store nem de composable. Não crie abstrações para estado que ninguém mais consome. - Composable que só repassa: se o composable não adiciona lógica (sem fetch, sem transformação, sem side-effects) e apenas repassa
storeToRefs, avalie se a indireção se justifica. Às vezes umstoreToRefsdireto é honestamente a melhor opção.
A regra de ouro que uso: adote o CAL quando múltiplos componentes consomem a mesma store ou quando a lógica de sincronização de estado é não-trivial. Na minha experiência, em qualquer projeto que tende a escalar — seja em features, em devs ou em complexidade de estado — esse threshold é atingido rápido.
Comparação: antes e depois
Antes (acesso direto)
ComponenteA.vue ──→ useProductStore()
ComponenteB.vue ──→ useProductStore()
ComponenteC.vue ──→ useProductStore()
PageX.vue ─────────→ useProductStore() + fetch + error handling
PageY.vue ─────────→ useProductStore() + fetch + error handling (duplicado)
- 5 pontos de acoplamento com a store
- Lógica de fetch duplicada
- Teste de cada componente exige setup de Pinia
Depois (CAL)
ComponenteA.vue ──→ props (dados vêm da page)
ComponenteB.vue ──→ props
ComponenteC.vue ──→ useProduct()
PageX.vue ─────────→ useProduct()
PageY.vue ─────────→ useProduct()
│
useProduct() ──→ useProductStore()
- 1 ponto de acoplamento com a store (o composable)
- Lógica de fetch centralizada
- Componentes testáveis com mock simples
Resumo
| Camada | Responsabilidade | Acessa store? | Acessa composable? |
|---|---|---|---|
| Store | Estado reativo + getters puros | — | Não |
| Composable | Fetch, lógica, side-effects, API pública | Sim | Pode compor outros |
| Componente | Renderização + interação do usuário | Nunca | Sim |
| Infraestrutura (middleware, plugin) | Setup, guards, config | Sim | Pode usar ambos |
O Composable Abstraction Layer não é um framework, não é uma lib, não exige dependências. É um padrão arquitetural — uma convenção sobre onde colocar cada tipo de lógica no ecossistema Vue 3 + Pinia. A Composition API já nos deu a ferramenta. O CAL é apenas a disciplina de usá-la como camada de abstração, e não só como repositório de funções utilitárias.
Cheguei nesse pattern depois de anos experimentando, errando e refinando. Não é perfeito, não é para todo projeto, mas em aplicações que tendem a escalar — em features, em devs, em complexidade — essa disciplina é a diferença entre um codebase que escala junto e um que só sobrevive.
Se você está em um projeto Vue 3 + Pinia que está crescendo e sente que o estado está virando um emaranhado, experimente aplicar o CAL em uma feature nova. Não precisa refatorar tudo de uma vez. Comece por uma store, crie o composable, ajuste os componentes. Quando o time sentir a diferença nos testes e nos refactors, a adoção acontece naturalmente.