So your Vue app has grown and now it feels harder to keep it tidy.
You used to have a simple setup, but now passing data around is getting out of hand. Components feel heavier.
And configuring advanced features in composables just doesn't feel smooth.
But we can do better.
I've got four patterns that help when Vue apps grow. They address different issues, yet work nicely together.
We can look at each one:
Let’s start with the Data Store Pattern.
When the app grows, we often find ourselves passing props down multiple layers just to get user data or shared state into the right component. So we place global state in a single composable.
Any component can use it.
This pattern moves shared data — like user info — into a global reactive store. Instead of pushing user
data through props and events, every component reads directly from this store.
It simplifies our entire data flow.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
import { reactive, toRefs } from 'vue'const state = reactive({user: {name: '',isLoggedIn: false,}})export function useUserStore() {const { user } = toRefs(state)function login(name) {user.value.name = nameuser.value.isLoggedIn = true}function logout() {user.value.name = ''user.value.isLoggedIn = false}return {user,login,logout}}
Here we define a single global state
object holding user
. The useUserStore()
function returns refs and methods, making them available anywhere.
We can consume it in our main component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
<template><div><div v-if="user.isLoggedIn">Welcome, {{ user.name }}!<button @click="logout">Logout</button></div><div v-else><input v-model="username" placeholder="Enter name" /><button @click="loginUser">Login</button></div></div></template><script setup>import { ref } from 'vue'import { useUserStore } from './useUserStore'const { user, login, logout } = useUserStore()const username = ref('')function loginUser() {if (username.value.trim()) {login(username.value)}}</script>
This gives us direct access to user
. We no longer need to pass it around.
Now that global state is in place, let’s move to the next pattern.
We often bundle too much logic inside composables. That makes them harder to test.
What if we placed business logic into pure functions, and used composables only for reactive wiring?
That’s what Thin Composables do.
We keep computations or formatting in a simple JS file. The composable just references it. Now tests are simpler — you test your logic as a normal function without Vue in the picture.
1 2 3 4
export function generateGreeting(name) {if (!name) return 'Hello, Guest!'return `Hello, ${name}!`}
1 2 3 4 5 6 7 8 9 10 11
import { computed } from 'vue'import { generateGreeting } from './lib/userLogic'import { useUserStore } from './useUserStore'export function useGreeting() {const { user } = useUserStore()const greeting = computed(() => generateGreeting(user.value.name))return { greeting }}
We import generateGreeting()
from userLogic.js
. The composable useGreeting()
just sets up a computed value. No heavy logic is needed inside useGreeting()
. Just a simple bridge.
We can now use it in our App.vue
component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
<template><div><div v-if="user.isLoggedIn">{{ greeting }}<button @click="logout">Logout</button></div><div v-else><input v-model="username" placeholder="Enter name" /><button @click="loginUser">Login</button></div></div></template><script setup>import { ref } from 'vue'import { useUserStore } from './useUserStore'import { useGreeting } from './useGreeting'const { user, login, logout } = useUserStore()const { greeting } = useGreeting()const username = ref('')function loginUser() {if (username.value.trim()) {login(username.value)}}</script>
Now the logic stands alone, and tests become simpler.
Let’s turn to configuration next.
As our composables gain features, we might add more parameters.
But passing multiple arguments can feel fragile. Instead, we can pass a single object with named options. This makes adding new settings painless.
We’ll use feature flags as an example.
We load flags from an API or use local defaults. If we had used positional arguments, changing the order or adding new parameters might break existing calls. With an options object, we easily extend configuration without breaking anything:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import { ref, watchEffect } from 'vue'export function useFeatureFlags(options = {}) {const {loadMethod = () => Promise.resolve({ darkMode: false }),defaultFlags = { darkMode: false }} = optionsconst flags = ref({ ...defaultFlags })watchEffect(async () => {const loaded = await loadMethod()flags.value = { ...defaultFlags, ...loaded }})return { flags }}
We destructure the defaults from the options
object.
If the caller doesn’t pass loadMethod
, we fall back to a simple default. If we want to add another option later—maybe cacheTime
—we just add it here.
No need to alter the calling code!
Let's add it to our App.vue
component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
<template><div :class="{ dark: flags.darkMode }"><div v-if="user.isLoggedIn">{{ greeting }}<button @click="logout">Logout</button></div><div v-else><input v-model="username" placeholder="Enter name" /><button @click="loginUser">Login</button></div></div></template><script setup>import { ref } from 'vue'import { useUserStore } from './useUserStore'import { useGreeting } from './useGreeting'import { useFeatureFlags } from './useFeatureFlags'const { user, login, logout } = useUserStore()const { greeting } = useGreeting()const username = ref('')async function mockLoadFlags() {return { darkMode: true }}const { flags } = useFeatureFlags({loadMethod: mockLoadFlags,defaultFlags: { darkMode: false }})function loginUser() {if (username.value.trim()) {login(username.value)}}</script><style>.dark {background: #333;color: #eee;}</style>
This approach makes the composable scalable. No matter how many options you add, the callers just pass an object.
Now we’ve solved state sharing, logic isolation, and composable configuration.
Let’s talk input flexibility.
Sometimes a composable accepts a parameter that can be a ref, a computed value, or a plain string.
Normally, you might write code to handle each case. But toValue()
helps unwrap these automatically.
We can also use ref
to do the opposite, but we'll focus on toValue()
for now.
This pattern makes composables more flexible. You pass either a ref or a literal — it doesn’t matter. The composable treats them the same:
1 2 3 4 5 6 7 8
import { computed, toValue } from 'vue'export function useUserLabel(name) {return computed(() => {const actualName = toValue(name)return actualName ? `User: ${actualName}` : 'No user selected'})}
We rely on toValue()
to handle different input forms. Here's how we can use it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
<script setup>import { ref } from 'vue'import { useUserStore } from './useUserStore'import { useGreeting } from './useGreeting'import { useFeatureFlags } from './useFeatureFlags'import { useUserLabel } from './useUserLabel'const { user, login, logout } = useUserStore()const { greeting } = useGreeting()const username = ref('')async function mockLoadFlags() {return { darkMode: true }}const { flags } = useFeatureFlags({loadMethod: mockLoadFlags,defaultFlags: { darkMode: false }})// Here we pass a function returning user’s nameconst userLabel = useUserLabel(() => user.value.name)function loginUser() {if (username.value.trim()) {login(username.value)}}</script><template><div :class="{ dark: flags.darkMode }"><div v-if="user.isLoggedIn"><p>{{ userLabel }}</p><p>{{ greeting }}</p><button @click="logout">Logout</button></div><div v-else><input v-model="username" placeholder="Enter name" /><button @click="loginUser">Login</button></div></div></template>
Now the composable doesn’t care if name
is ref-based or plain. It just works!
We started with a global Data Store to clean up state handling.
Then we made composables thinner by offloading logic to pure functions.
After that, we tackled complexity in composable configuration by using an options object.
Finally, we introduced flexible arguments so composables can handle different input types gracefully.
So, now your code feels lighter and more flexible!
Because with these patterns in place, your Vue app can grow without becoming a tangled mess.
If you enjoyed learning about these patterns, check out my course, Composable Design Patterns. It's more than just a course:
Master composable patterns to write more maintainable, testable, and flexible Vue applications.