Composables over Mixins: State Management in Vue 3
How composables replaced mixins, Vuex, and half my utility files, with patterns for sharing reactive state cleanly.
When I started with Vue, mixins were the standard way to share logic between components. They worked until they didn't: name collisions, implicit dependencies, and the constant question of "where does this method come from?" Composables replaced all of that, and along the way, they replaced Vuex for most of my state management needs too.
The Mixin Problem
Mixins merge their properties into the component that uses them. If a mixin defines a data() function with a loading property, every component using that mixin has this.loading. The problem is that this merging is invisible:
// mixins/withLoading.js
export default {
data() {
return { loading: false, error: null }
},
methods: {
async fetchData(url) {
this.loading = true
try {
const res = await fetch(url)
return await res.json()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
}
}
}
If the component also defines loading in its own data(), Vue silently merges them with the component's version winning. If two mixins both define fetchData, one overwrites the other. No warnings. No errors. Just bugs that are incredibly hard to trace.
Composables Make Dependencies Explicit
The same logic as a composable:
export function useAsync<T>(fetcher: () => Promise<T>) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await fetcher()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
Usage is explicit. You see exactly what comes from the composable:
<script setup lang="ts">
const { data: projects, loading, execute } = useAsync(() =>
$fetch('/api/projects')
)
onMounted(execute)
</script>
No name collisions because you control the destructured names. No implicit dependencies because every value is returned explicitly. No merge surprises because there's nothing to merge.
Shared State Without Vuex
Vuex (and Pinia, its successor) is valuable for complex applications with deeply nested components that need to share state. But for many use cases (a global auth state, a theme toggle, a notification queue) a composable with module-level state is simpler.
// composables/useAuth.ts
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)
export function useAuth() {
async function login(credentials: LoginCredentials) {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: credentials,
})
user.value = response.user
}
function logout() {
user.value = null
navigateTo('/login')
}
return {
user: readonly(user),
isAuthenticated,
login,
logout,
}
}
The ref is defined outside the function, at the module level. Every component that calls useAuth() shares the same user ref. Change it in one place, and every component reactively updates.
readonly(user) prevents components from mutating the state directly. They must go through login() or logout(), which keeps state changes predictable.
Patterns I Use Regularly
useScrollLock prevents body scroll when a modal or drawer is open:
export function useScrollLock() {
function lock() {
document.body.style.overflow = 'hidden'
}
function unlock() {
document.body.style.overflow = ''
}
onUnmounted(unlock)
return { lock, unlock }
}
useDebounce delays a reactive value's update:
export function useDebounce<T>(source: Ref<T>, delay: number) {
const debounced = ref(source.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout>
watch(source, (val) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = val
}, delay)
})
return debounced
}
useMediaQuery for reactive media query matching:
export function useMediaQuery(query: string) {
const matches = ref(false)
onMounted(() => {
const mql = window.matchMedia(query)
matches.value = mql.matches
const handler = (e: MediaQueryListEvent) => {
matches.value = e.matches
}
mql.addEventListener('change', handler)
onUnmounted(() => mql.removeEventListener('change', handler))
})
return matches
}
When to Use Pinia Instead
Composables handle most state management needs, but Pinia is better when:
- You need DevTools integration because Pinia's Vue DevTools plugin lets you inspect and time-travel through state changes
- The state is complex enough to benefit from actions/getters separation
- Multiple composables would need to share and coordinate state in ways that become tangled
- You want SSR hydration built-in without manual work
For this portfolio, composables handle everything. For a SaaS dashboard with complex data flows, I'd reach for Pinia. The choice isn't religious. It's about matching the tool to the complexity.
The Migration Path
If you're on an Options API codebase with mixins:
- Identify each mixin's responsibility
- Create a composable that returns the same data and methods
- Replace the mixin usage in one component at a time
- Delete the mixin when no components reference it
You don't need to migrate everything at once. Vue 3 supports both patterns simultaneously. Move the mixins that cause the most confusion first, and work outward.