Comparison of Different Reactive State Management Techniques in Vue
You can organize the exchange of [reactive] data in a Vue 3 application in several ways.
1. Prop Drilling
Prop drilling is a situation where props are passed through several components that do not use them themselves, only to deliver data to the component that actually needs them.
Let's look at an example:
<script setup>
const user = {
name: 'John',
email: 'john@example.com'
}
</script>
<template>
<div>
<Header :user="user" />
</div>
</template>
<!-- Header.vue -->
<script setup>
const props = defineProps(['user'])
</script>
<template>
<header>
<Nav :user="user" />
</header>
</template>
<!-- Nav.vue -->
<script setup>
const props = defineProps(['user'])
</script>
<template>
<nav>
<UserMenu :user="user" />
</nav>
</template>
<!-- UserMenu.vue -->
<script setup>
const props = defineProps(['user'])
</script>
<template>
<div>
{{ user.name }}
</div>
</template>
Transmission scheme for prop drilling:
App.vue (:user)
|
+-- Header.vue (:user)
|
+-- Nav.vue (:user)
|
+-- UserMenu.vue (использует user)
Pros:
- Explicit data transmission
- Easy to track data flow
- No additional tools required
- Works well for small applications
Cons:
- Clutters code with a large number of props
- Complicates refactoring
- Intermediate components receive unnecessary props
- Difficult to maintain with deep nesting
2. Provide/Inject
Vue provides a built-in provide/inject mechanism for passing data through multiple component levels:
<!-- App.vue -->
<script setup>
import { provide } from 'vue'
const user = {
name: 'John',
email: 'john@example.com'
}
provide('user', user)
</script>
<!-- UserMenu.vue -->
<script setup>
import { inject } from 'vue'
const user = inject('user')
</script>
<template>
<div>
{{ user.name }}
</div>
</template>
Provide/Inject workflow scheme:
App.vue
|
+-- provide('user')
|
+-- Header.vue
|
+-- Nav.vue
|
+-- UserMenu.vue
|
+-- inject('user')
Pros:
- Built-in Vue solution
- Easy to use
- No additional dependencies required
- Suitable for passing data through many levels
Cons:
- Difficult to trace data source
- No Vue DevTools support
- Possible injection name conflicts
- No built-in typing (requires additional TypeScript configuration)
3. Composition API
Another approach is to use Vue Reactivity API
directly, by exporting reactive state from a js
module:
// useUser.js
import { ref } from 'vue'
export const user = ref({
name: 'John',
email: 'john@example.com'
})
export function updateUser(newUser) {
// ...
}
<!-- UserMenu.vue -->
<script setup>
import { user } from './useUser'
</script>
<template>
<div>
{{ user.name }}
</div>
</template>
Composition API workflow scheme:
useUser.js (ref + методы)
|
+-- ComponentA.vue (импорт + использование)
|
+-- ComponentB.vue (импорт + использование)
|
+-- ComponentC.vue (импорт + использование)
You can keep both the ref
and its working logic in one file, obtaining an analogue of an OOP object.
With this construction, you can encounter cyclic JS imports - if A imports B, where b = ref()
is defined, and B imports C, which uses b (for example, in watch
), then b will be "undefined" in C.
Creating an explicit singleton would be a more competent solution (for example, creating a composable function and exporting/providing it for the entire application, or other variations).
Pros:
- Excellent logic encapsulation
- Allows full use of
Vue Reactivity API
- Easy to test
- Good reusability
- Explicit dependencies
Cons:
- Requires proper code structuring
- No centralized state management
- Harder to debug
- Possible cyclic dependencies
4. Global Store (Pinia)
For more complex cases, you can use the state manager Pinia
:
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: {
name: 'John',
email: 'john@example.com'
}
})
})
<!-- UserMenu.vue -->
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
<template>
<div>
{{ userStore.user.name }}
</div>
</template>
Pinia workflow scheme:
stores/user.js (state + actions + getters)
|
+-- DevTools <--> Pinia Store
|
+-- ComponentA.vue (useUserStore())
|
+-- ComponentB.vue (useUserStore())
|
+-- ComponentC.vue (useUserStore())
Pros:
- Centralized state management
- Integration with Vue DevTools
SSR
support- Built-in TypeScript support
- Better suited for working in a large team
Cons:
- Additional dependency
- Performance drop compared to simply using ref
- Excessive for small applications
- Requires configuration
- More code for simple cases
5. Event Bus
Another approach is using an event bus. Although Vue 3 doesn't have a built-in Event Bus like Vue 2, we can use external libraries like mitt
:
// eventBus.js
import mitt from 'mitt'
export const eventBus = mitt()
// Типизация событий (опционально)
type Events = {
'user-updated': { name: string, email: string }
'user-deleted': { id: number }
}
export const typedEventBus = mitt<Events>()
Использование:
<!-- ComponentA.vue -->
<script setup>
import { eventBus } from './eventBus'
const sendUser = () => {
eventBus.emit('user-updated', {
name: 'John',
email: 'john@example.com'
})
}
</script>
<!-- ComponentB.vue -->
<script setup>
import { eventBus } from './eventBus'
import { onMounted, onUnmounted } from 'vue'
const handleUser = (user) => {
console.log('Получены данные пользователя:', user)
}
onMounted(() => {
eventBus.on('user-updated', handleUser)
})
onUnmounted(() => {
eventBus.off('user-updated', handleUser)
})
</script>
Using an Event Bus is most often unjustified and introduces confusion into the code, so it is sometimes called an anti-pattern for frontend.
There are cases where Event Bus can be useful, for example, when implementing WebSocket work. But in this case, it works as a Proxy
or Facade
for the WS client (to make reconnection possible when the connection is broken), rather than as part of the reactive system.
Event Bus workflow scheme:
eventBus.js
|
+-- ComponentA.vue (emit)
| |
| +-- событие 'user-updated' ----+
| |
+-- ComponentB.vue (<---------------+)
| |
| +-- on('user-updated')
|
+-- ComponentC.vue
|
+-- on('user-updated')
Pros:
- Simple implementation
- Flexibility in component communication
- No need for hierarchical component relationships
- Easy to add new subscribers
Cons:
- Difficult to track data flow
- Possible memory leaks with improper cleanup
- No default event typing
When to Use What?
Prop Drilling is optimal when:
- The application has a simple and shallow component structure
- The number of passed props is small
- Data is needed only in nearby components
- No need for complex state management logic
- Maximum data transmission transparency is important
- The project is small or in the prototyping stage
Provide/Inject is well-suited for:
- Passing data through many component levels
- When data is needed only in a specific component tree branch
- Cases with theming or localization
Composition API is optimal when:
- Reusable logic is needed
- Data is used in different parts of the application
- Encapsulation of complex logic is required
Pinia is the best choice if:
- Global state is needed
SSR
support is required- Vue DevTools support is necessary
- Typing (TypeScript) is important
Event Bus can be useful in cases:
- When the main data transmission system is built on it
- Working with WebSocket
- Specific scenarios
- Simple communication between unrelated components
Conclusions
- Prop drilling is not always a problem
- Provide/inject is a simple built-in solution
- Module refs are a powerful tool for reusable logic
- Pinia is a comprehensive state management solution
- Event Bus can be useful in specific cases
Choosing an approach depends on the specific case and project requirements. Sometimes simple prop passing can be the most understandable solution.