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.

ThemeProvider.vuevue
<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>
DeeplyNestedButton.vuevue
<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.

eventBus.jsjavascript
import Vue from 'vue'
export const EventBus = new Vue()
NotificationBell.vuevue
<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.

FormWithImperativeFocus.vuevue
<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.

paginationMixin.jsjavascript
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++
    }
  }
}
PaginatedList.vuevue
<!-- 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>
CriteriaMixinsScoped Slots
Data flowImplicit — merged into the instance, hidden dependency on host component propertiesExplicit — data flows through slot props, visible at the call site
Naming collisionsSilent, resolved by priority rules that are easy to forgetImpossible — slot props are just function arguments
Inversion of controlLow — mixin dictates behavior, host adaptsHigh — parent controls rendering entirely, child just supplies data
Best use caseCross-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.

store/modules/inventory.jsjavascript
// 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
}
store/index.jsjavascript
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)
})
InventoryView.vuevue
<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

DynamicFormBuilder.vuevue
<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>
ParentUsage.vuevue
<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.

router/index.jsjavascript
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.jsjavascript
// 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 entry

Navigation Guards

Guard TypeLocationTypical Use
Global beforeEachrouter/index.jsAuthentication check across every route
Global beforeResolverouter/index.jsRuns after in-component guards, before confirmation — final data-fetch gate
Per-route beforeEnterRoute definition objectRoute-specific checks (e.g. feature flag gating a single page)
In-component beforeRouteEnterComponent optionsCannot access this yet (component not created) — use the next(vm => ...) callback
In-component beforeRouteUpdateComponent optionsSame route, changed params (e.g. /products/1 → /products/2) — component is reused, not recreated
In-component beforeRouteLeaveComponent optionsConfirm navigation away (e.g. unsaved form warning)
router/guards.jsjavascript
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()
  }
})
DashboardSettings.vuevue
<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.

directives/clickOutside.jsjavascript
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__
  }
}
main.js (directive registration)javascript
import { clickOutside } from '@/directives/clickOutside'
Vue.directive('click-outside', clickOutside)
DropdownMenu.vuevue
<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

directives/lazyImage.jsjavascript
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()
  }
}
DashboardOverview.vuevue
<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>