Advanced Component Communication
Props and events cover parent-child communication. Once components are siblings, deeply nested, or dynamically related, three other mechanisms come into play — each with sharp trade-offs.
Provide/Inject
provide/inject lets an ancestor expose data to any descendant, no matter how deeply nested, without prop drilling through every intermediate layer.
<script>
export default {
name: 'ThemeProvider',
data() {
return {
theme: { mode: 'dark', accent: '#16a34a' }
}
},
provide() {
// Non-reactive by default — provide() runs once at creation.
// To keep reactivity, provide a reference to `this` or use a reactive object directly.
return {
theme: this.theme,
updateTheme: this.updateTheme
}
},
methods: {
updateTheme(patch) {
Object.assign(this.theme, patch)
}
}
}
</script><script>
export default {
name: 'DeeplyNestedButton',
inject: ['theme', 'updateTheme'],
// theme is the SAME object reference provided above, so mutating its
// properties (not reassigning it) still triggers reactive updates
// in every component that reads theme.mode / theme.accent.
}
</script>
<template>
<button :style="{ background: theme.accent }" @click="updateTheme({ mode: 'light' })">
Toggle Theme
</button>
</template>Event Buses and Their Memory Leak Danger
Before Vue 3 removed $on/$off/$emit from the root instance, a common Vue 2 pattern was a shared empty Vue instance used purely as a pub/sub channel between unrelated components.
import Vue from 'vue'
export const EventBus = new Vue()<script>
import { EventBus } from '@/eventBus'
export default {
created() {
EventBus.$on('notification:new', this.handleNotification)
},
beforeDestroy() {
// MANDATORY — without this, the listener holds a closure reference
// to `this`, keeping the entire component instance (and its DOM,
// if detached incorrectly) alive in memory after the component
// is unmounted. Every route navigation compounds the leak.
EventBus.$off('notification:new', this.handleNotification)
},
methods: {
handleNotification(payload) {
this.$emit('bell-ring', payload)
}
}
}
</script>parent
$refs gives direct access to a child component instance or DOM element; $parent walks up the ownership chain. Both bypass the props-down/events-up contract and should be treated as escape hatches, not primary communication tools.
<template>
<div>
<CustomInput ref="emailInput" v-model="email" />
<button @click="focusEmail">Edit Email</button>
</div>
</template>
<script>
export default {
data: () => ({ email: '' }),
methods: {
focusEmail() {
// Legitimate use: imperative DOM focus cannot be expressed declaratively
this.$refs.emailInput.focus()
}
}
}
</script>Reusability Patterns: Mixins vs Scoped Slots
Mixins merge options (data, methods, lifecycle hooks) into a component at definition time. Scoped slots let a child expose data to a parent-authored template. They solve overlapping problems with very different failure modes.
export const paginationMixin = {
data() {
return { currentPage: 1, perPage: 20 }
},
computed: {
totalPages() {
// Assumes `this.items` exists on the consuming component —
// an implicit contract that isn't visible from the mixin's own code.
return Math.ceil(this.items.length / this.perPage)
}
},
methods: {
nextPage() {
if (this.currentPage < this.totalPages) this.currentPage++
}
}
}<!-- Scoped slot approach: explicit, inspectable, no implicit merging -->
<template>
<div>
<slot
v-for="item in pagedItems"
:key="item.id"
:item="item"
:page="currentPage"
/>
<button @click="currentPage++" :disabled="currentPage >= totalPages">Next</button>
</div>
</template>
<script>
export default {
props: { items: { type: Array, required: true }, perPage: { type: Number, default: 20 } },
data: () => ({ currentPage: 1 }),
computed: {
totalPages() { return Math.ceil(this.items.length / this.perPage) },
pagedItems() {
const start = (this.currentPage - 1) * this.perPage
return this.items.slice(start, start + this.perPage)
}
}
}
</script>| Criteria | Mixins | Scoped Slots |
|---|---|---|
| Data flow | Implicit — merged into the instance, hidden dependency on host component properties | Explicit — data flows through slot props, visible at the call site |
| Naming collisions | Silent, resolved by priority rules that are easy to forget | Impossible — slot props are just function arguments |
| Inversion of control | Low — mixin dictates behavior, host adapts | High — parent controls rendering entirely, child just supplies data |
| Best use case | Cross-cutting non-visual logic shared with Composition API-like discipline (rare in Vue 2) | Any case where a child manages data/state but the parent should control markup |
Global State with Vuex 3
Vuex is Vue 2's official state management library, built around a strict unidirectional data flow: components dispatch actions, actions commit mutations, mutations are the only code allowed to change state directly.
// Module-isolated store slice — namespaced to avoid action/mutation name collisions
// across modules as the app scales.
const state = () => ({
products: [],
loading: false
})
const getters = {
totalUnits: (state) => state.products.reduce((sum, p) => sum + p.stock, 0),
lowStock: (state) => state.products.filter((p) => p.stock > 0 && p.stock < 10)
}
const mutations = {
SET_LOADING(state, value) {
state.loading = value
},
SET_PRODUCTS(state, products) {
state.products = products
},
ADJUST_STOCK(state, { id, delta }) {
const product = state.products.find((p) => p.id === id)
if (product) product.stock = Math.max(0, product.stock + delta)
}
}
const actions = {
async fetchProducts({ commit }) {
commit('SET_LOADING', true)
try {
const res = await fetch('/api/products')
const data = await res.json()
commit('SET_PRODUCTS', data)
} finally {
commit('SET_LOADING', false)
}
},
adjustStock({ commit }, payload) {
commit('ADJUST_STOCK', payload)
}
}
export default {
namespaced: true,
state,
getters,
mutations,
actions
}import Vue from 'vue'
import Vuex from 'vuex'
import inventory from './modules/inventory'
import auth from './modules/auth'
Vue.use(Vuex)
export default new Vuex.Store({
modules: { inventory, auth },
strict: import.meta.env.DEV // throws if state is mutated outside a mutation, dev-only (perf cost)
})<script>
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState('inventory', ['products', 'loading']),
...mapGetters('inventory', ['totalUnits', 'lowStock'])
},
created() {
this.fetchProducts()
},
methods: {
...mapActions('inventory', ['fetchProducts', 'adjustStock'])
}
}
</script>Practical Example: Dynamic Form Builder with Scoped Slots + Vuex
<template>
<form class="form-builder" @submit.prevent="handleSubmit">
<div v-for="field in schema" :key="field.key" class="form-builder__field">
<!-- Scoped slot: parent decides the exact input markup per field type -->
<slot
:name="field.type"
:field="field"
:value="formState[field.key]"
:setValue="(val) => setField(field.key, val)"
>
<!-- Fallback default rendering if no matching named slot is provided -->
<label :for="field.key">{{ field.label }}</label>
<input
:id="field.key"
:value="formState[field.key]"
@input="setField(field.key, $event.target.value)"
/>
</slot>
</div>
<button type="submit">Save</button>
</form>
</template>
<script>
import { mapActions } from 'vuex'
export default {
props: {
schema: { type: Array, required: true } // [{ key, label, type }]
},
data() {
const formState = {}
this.schema.forEach((f) => { formState[f.key] = f.default ?? '' })
return { formState }
},
methods: {
...mapActions('forms', ['submitForm']),
setField(key, value) {
this.$set(this.formState, key, value) // needed if key wasn't in initial data — see Module 5
},
handleSubmit() {
this.submitForm({ ...this.formState })
}
}
}
</script>
<style scoped>
.form-builder__field { margin-bottom: 1rem; display: flex; flex-direction: column; gap: 0.25rem; }
</style><template>
<DynamicFormBuilder :schema="productSchema">
<!-- Custom rendering for 'select' type fields, data injected via scoped slot -->
<template #select="{ field, value, setValue }">
<label :for="field.key">{{ field.label }}</label>
<select :id="field.key" :value="value" @change="setValue($event.target.value)">
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
</template>
</DynamicFormBuilder>
</template>Vue Router v3 Deep Dive
Vue Router 3.x targets Vue 2. It handles dynamic segments, nested route hierarchies, and code splitting via dynamic import(), which webpack/Vite both recognize as a chunk boundary.
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '@/store'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'home',
// Route-level code splitting: this chunk is only fetched when visited
component: () => import('@/views/HomeView.vue')
},
{
path: '/products/:id',
name: 'product-detail',
component: () => import('@/views/ProductDetailView.vue'),
props: true // route.params.id is passed as a prop, not read via $route
},
{
path: '/dashboard',
component: () => import('@/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true },
children: [
// Nested routes render into the parent's <router-view>
{ path: '', name: 'dashboard-overview', component: () => import('@/views/dashboard/Overview.vue') },
{ path: 'settings', name: 'dashboard-settings', component: () => import('@/views/dashboard/Settings.vue') }
]
}
]
const router = new VueRouter({
mode: 'history',
base: import.meta.env.BASE_URL,
routes
})
export default router// Programmatic navigation — never mutate window.location directly, it forces a full reload
this.$router.push({ name: 'product-detail', params: { id: 42 } })
this.$router.replace({ path: '/dashboard/settings' }) // no history entry added
this.$router.go(-1) // back one entryNavigation Guards
| Guard Type | Location | Typical Use |
|---|---|---|
| Global beforeEach | router/index.js | Authentication check across every route |
| Global beforeResolve | router/index.js | Runs after in-component guards, before confirmation — final data-fetch gate |
| Per-route beforeEnter | Route definition object | Route-specific checks (e.g. feature flag gating a single page) |
| In-component beforeRouteEnter | Component options | Cannot access this yet (component not created) — use the next(vm => ...) callback |
| In-component beforeRouteUpdate | Component options | Same route, changed params (e.g. /products/1 → /products/2) — component is reused, not recreated |
| In-component beforeRouteLeave | Component options | Confirm navigation away (e.g. unsaved form warning) |
import router from './index'
import store from '@/store'
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
const isAuthenticated = store.getters['auth/isAuthenticated']
if (requiresAuth && !isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
}
})<script>
export default {
data: () => ({ isDirty: false }),
beforeRouteLeave(to, from, next) {
if (this.isDirty && !window.confirm('Discard unsaved changes?')) {
next(false) // cancel navigation
} else {
next()
}
}
}
</script>Custom Directives & Filters
Custom directives are the correct escape hatch for direct DOM manipulation that has no declarative template equivalent — click-outside detection, lazy image loading, autofocus, tooltips positioned via measurement.
export const clickOutside = {
bind(el, binding) {
el.__clickOutsideHandler__ = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.__clickOutsideHandler__)
},
unbind(el) {
// Mandatory cleanup — same leak risk pattern as the EventBus example above
document.removeEventListener('click', el.__clickOutsideHandler__)
delete el.__clickOutsideHandler__
}
}import { clickOutside } from '@/directives/clickOutside'
Vue.directive('click-outside', clickOutside)<template>
<div v-click-outside="closeMenu" class="dropdown">
<button @click="isOpen = !isOpen">Menu</button>
<ul v-show="isOpen">
<li>Option A</li>
<li>Option B</li>
</ul>
</div>
</template>
<script>
export default {
data: () => ({ isOpen: false }),
methods: { closeMenu() { this.isOpen = false } }
}
</script>Practical Example: Authenticated Dashboard with Lazy-Loaded Images
export const lazyImage = {
inserted(el, binding) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
})
}, { rootMargin: '50px' })
observer.observe(el)
el.__lazyObserver__ = observer
},
unbind(el) {
el.__lazyObserver__?.disconnect()
}
}<template>
<div class="dashboard-overview">
<h1>Welcome back, {{ user.name }}</h1>
<div class="dashboard-overview__grid">
<img
v-for="item in recentProducts"
:key="item.id"
v-lazy-image="item.imageUrl"
src="/placeholder.svg"
:alt="item.name"
class="dashboard-overview__thumb"
/>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
computed: {
...mapState('auth', ['user']),
...mapState('inventory', { recentProducts: (state) => state.products.slice(0, 12) })
},
beforeRouteEnter(to, from, next) {
// No `this` available yet — use the callback form to access the instance
// once the component has been created and route is confirmed.
next((vm) => {
vm.$store.dispatch('inventory/fetchProducts')
})
}
}
</script>
<style scoped>
.dashboard-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.dashboard-overview__thumb {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
background: #f1f1f1;
}
</style>