Scaling Your Vue App: 4 Proven Patterns to Keep It Clean

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:

  • The Data Store Pattern to avoid messy prop drilling.
  • The Thin Composables Pattern to separate logic from reactivity.
  • The Options Object Pattern to simplify composable configuration.
  • The Flexible Arguments Pattern to adapt to varying input types.

Let’s start with the Data Store Pattern.

1. Using a Data Store Pattern to Avoid Prop Drilling

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.

useUserStore.js
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 = name
user.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:

App.vue
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.

2. Using Thin Composables to Separate Logic

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.

lib/userLogic.js
1
2
3
4
export function generateGreeting(name) {
if (!name) return 'Hello, Guest!'
return `Hello, ${name}!`
}
useGreeting.js
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:

App.vue
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.

3. Using an Options Object for Composable Configuration

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:

useFeatureFlags.js
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 }
} = options
const 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:

App.vue
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.

4. Using Flexible Arguments to Handle Different Input Types

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:

useUserLabel.js
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:

App.vue
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 name
const 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!

Putting It All Together

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.

Learn More Composable Patterns

If you enjoyed learning about these patterns, check out my course, Composable Design Patterns. It's more than just a course:

  • Learn 5+ proven patterns for writing better composables (with more coming in early 2025)
  • Step-by-step refactoring guides to see the patterns in action
  • Real-world demos showing how to implement each pattern
  • In-depth pattern overviews covering edge cases and variations
  • Article-style examples if you prefer a more traditional approach

Master composable patterns to write more maintainable, testable, and flexible Vue applications.

Learn more here.