Leveraging Vue 3 Composition API for Enterprise Apps.
Beyond the Basics
Every Vue 3 tutorial teaches you ref, reactive, and computed. They show you a counter, maybe a todo list, and call it a day. But enterprise applications demand patterns that these tutorials never cover.
When your app has 200+ components, shared state across deeply nested trees, and business logic that spans multiple domains — you need composables that are testable, type-safe, and composable themselves.
The Composable Architecture
Think of composables as the building blocks of your application logic. Each one should follow a single principle: one concern, one composable.
// composables/useAuth.ts
export function useAuth() {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)
async function login(credentials: Credentials) {
const response = await authApi.login(credentials)
user.value = response.user
tokenStore.set(response.token)
}
async function logout() {
await authApi.logout()
user.value = null
tokenStore.clear()
}
return {
user: readonly(user),
isAuthenticated,
login,
logout,
}
}
Notice: we return readonly(user) to prevent external mutations. The composable owns its state — consumers can read it, but mutations go through defined functions.
Dependency Injection with Provide/Inject
For cross-cutting concerns like authentication, theme, or feature flags, use Vue’s dependency injection:
// plugins/auth.ts
const AUTH_KEY: InjectionKey<ReturnType<typeof useAuth>> = Symbol('auth')
export function provideAuth() {
const auth = useAuth()
provide(AUTH_KEY, auth)
return auth
}
export function useAuthContext() {
const auth = inject(AUTH_KEY)
if (!auth) throw new Error('Auth not provided')
return auth
}
This pattern gives you singleton state without global variables. The state lives in the component tree, respects Vue’s reactivity system, and is trivially testable.
Composable Composition
The real power emerges when composables use other composables:
export function useProtectedResource<T>(
fetcher: () => Promise<T>
) {
const { isAuthenticated } = useAuthContext()
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const execute = async () => {
if (!isAuthenticated.value) {
error.value = new Error('Not authenticated')
return
}
try {
data.value = await fetcher()
} catch (e) {
error.value = e as Error
}
}
watch(isAuthenticated, (authed) => {
if (authed) execute()
else data.value = null
})
return { data: readonly(data), error: readonly(error), execute }
}
Testing Composables
Composables are pure functions that return reactive state. Testing them is straightforward with @vue/test-utils:
import { mount } from '@vue/test-utils'
import { useAuth } from './useAuth'
function withSetup<T>(composable: () => T) {
let result: T
mount({
setup() {
result = composable()
return () => null
},
})
return result!
}
describe('useAuth', () => {
it('starts unauthenticated', () => {
const { isAuthenticated } = withSetup(() => useAuth())
expect(isAuthenticated.value).toBe(false)
})
})
State Management Without Vuex
For most enterprise apps, you don’t need a dedicated state management library. Composables with provide/inject cover 90% of use cases. Reserve Pinia for truly global state that must persist across route navigations.
The architecture becomes:
- Local state:
refandreactiveinside components - Shared state: Composables with
provide/inject - Global state: Pinia stores (authentication, user preferences, feature flags)
- Server state: Dedicated data-fetching composables with caching
Key Principles
- Composables own their state. Never expose raw
refs — usereadonly. - One concern per composable. If it does auth AND data fetching, split it.
- Type everything. Use
InjectionKey<T>for provide/inject. Use generics for reusable composables. - Test in isolation. Composables are functions. Test them like functions.
The Composition API isn’t just an alternative to the Options API. Used correctly, it’s an architecture pattern that scales to applications of any size.