VuePublished 2/12/2024

Vue Component Communication: When to Use props, emit, provide/inject, and Store

Break down how props, emit, provide/inject, and a Store each solve component communication in Vue, and walk away with a practical framework for picking the right tool based on how two components are related.

So How Do Components Actually Talk to Each Other?

Once a Vue project grows past a handful of components, you'll run into this problem constantly: a piece of data needs to get from component A to component B, and you're not sure what the right way to wire that up is.

With two or three components, pretty much anything works. But as the tree gets deeper and components multiply, picking the wrong communication method starts to cost you. Maybe you just need a value to reach a grandchild component, and suddenly you're writing the same props line at every level in between. Or you swing the other way and dump everything into a global store, so any component can mutate state it has no business touching — and good luck tracing a bug back to its source after that.

Vue actually gives you four ways to handle this: props, emit, provide/inject, and a Store (Pinia, these days). They're not interchangeable options — each one maps to a different relationship between components. Once you understand what problem each one actually solves, picking the right tool stops being a matter of taste and becomes something you can derive directly from how the components relate to each other.

props: A Parent Handing Something Down

Let's start with the simplest case. props handles data flowing from a parent component to a child component — always top-down, never the other way.

vue
<!-- Parent component -->
<script setup>
import { ref } from 'vue'
import ChildItem from './ChildItem.vue'
 
const username = ref('Alice')
</script>
 
<template>
  <ChildItem :name="username" />
</template>
vue
<!-- Child component: ChildItem.vue -->
<script setup>
defineProps<{ name: string }>()
</script>
 
<template>
  <p>{{ name }}</p>
</template>

Think of props as a parent handing a list down to a kid: whatever's on the list is what the kid gets, and the kid doesn't get to rewrite it. That's exactly why Vue enforces one-way data flow for props — a child receives them read-only, and mutating a prop directly inside the child triggers a dev-mode warning.

Why the one-way restriction? Because if both parent and child can mutate the same piece of data, you lose any sense of which one is the source of truth. If a child genuinely needs its own mutable copy, the right move is to use the prop as an initial value and wrap a separate ref around it:

js
const props = defineProps<{ initialCount: number }>()
const count = ref(props.initialCount) // your own copy to manage

props fits one specific case: data only needs to travel down a single level. The parent already has the value, the child needs it, and there's no need for anything to flow back up. The moment a component relationship turns into "the child needs to notify the parent" or "this needs to cross several levels," props alone stops being enough.

emit: A Child Reporting Back

A child can't mutate props directly, but it still needs a way to tell the parent when something happens — a delete button gets clicked, a form gets submitted. That's what emit is for.

vue
<!-- Child component -->
<script setup>
const emit = defineEmits<{ delete: [id: number] }>()
 
function handleClick() {
  emit('delete', 42) // tell the parent: someone wants to delete id 42
}
</script>
 
<template>
  <button @click="handleClick">Delete</button>
</template>
vue
<!-- Parent component -->
<template>
  <ChildItem @delete="onDelete" />
</template>
 
<script setup>
function onDelete(id: number) {
  console.log(`Received a delete request for id: ${id}`)
}
</script>

emit is the child reporting back: "Something happened — do you want to handle it?" The child itself never makes the call; it just fires the event with whatever information the parent needs, and leaves the actual decision-making to the parent.

Put props and emit together and you get a complete two-way conversation between parent and child: props carries data down, emit carries events up. This is also the pattern Vue's own docs consistently recommend, because the data flow stays legible — you can look at a single component's code and reason about exactly what it receives and what it sends out, without having to chase down some global state to see who changed what.

Where it fits: a direct parent-child relationship, where the child needs to report a user interaction or state change back up. If a grandchild needs to notify a great-grandparent, relaying emit through every level in between gets tedious fast — that's your cue to reach for something else.

provide/inject: A Bulletin Board for Descendants

Say you've got a deep component tree: App → Page → Section → Widget, and Widget needs a theme setting defined all the way up in App. If you tried to do this with props, you'd have to make Page and Section both pass along a prop they never actually use themselves. There's a name for this: prop drilling.

provide/inject exists specifically to get around that.

vue
<!-- App.vue: the ancestor -->
<script setup>
import { provide, ref } from 'vue'
 
const theme = ref('dark')
provide('theme', theme)
</script>
vue
<!-- Widget.vue: any descendant, no matter how deep -->
<script setup>
import { inject } from 'vue'
 
const theme = inject('theme')
</script>
 
<template>
  <div :class="theme">Content</div>
</template>

Think of provide/inject as an ancestor pinning something to a bulletin board somewhere up the component tree: it doesn't matter how many levels separate them — any descendant that knows the board's key (here, 'theme') can walk up and read it directly, with nobody in between having to relay it manually. Worth being clear about one thing, though: that board is one-way. Only the ancestor that calls provide gets to post to it. Descendants can only read — there's no channel back up to the ancestor, and no way for descendants to talk to each other through it either.

Two things worth watching for. First, whatever inject returns is still reactive by default if you provided a ref — but to stop descendants from mutating the ancestor's state and making the data flow unpredictable, it's common to pair it with readonly:

js
import { provide, readonly, ref } from 'vue'
 
const theme = ref('dark')
provide('theme', readonly(theme))

Second, provide/inject doesn't give you the explicit typing and traceable path that props does. Overuse it and the dependency between components turns opaque — look at Widget.vue on its own, and there's no way to tell which ancestor is providing theme; you just have to climb the tree until you find it.

Where it fits: data that needs to cross several levels and stays relatively stable — theme settings, locale, current-user info: the kind of thing an entire subtree might need but that rarely changes. For a relationship that's only two or three levels deep, props plus emit is usually still the more direct, more traceable choice.

Store: A Bulletin Board for the Whole Company

provide/inject solves cross-level communication within a single component tree. But what if two components aren't even on the same tree — say, a cart icon in the nav bar and a checkout form living on a completely different route, both needing the same cart data? That's out of a component tree's reach entirely. What you need is global state that's accessible across components, even across routes — a Store (in the Vue ecosystem, that almost always means Pinia these days).

js
// stores/cart.js
import { defineStore } from 'pinia'
 
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] }),
  actions: {
    addItem(item) {
      this.items.push(item)
    },
  },
})
vue
<!-- Any component, on any route, in any component tree -->
<script setup>
import { useCartStore } from '@/stores/cart'
 
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Keyboard' })
</script>

A Store is like a bulletin board in the company lobby — anyone from any department, on any floor, can walk up and read it, and just as easily pin something new to it. That "accessible from anywhere" property is exactly what makes it useful, and exactly why it deserves some restraint: cram every piece of state into a Store and the dependencies between components turn untraceable. Good luck figuring out who changed a given piece of state, and when, without combing through the entire codebase.

Where it fits: state that genuinely needs to be shared across component trees and isn't tied to a single page's lifecycle — logged-in user state, a shopping cart, a form draft that needs to survive a page change. If it's purely a UI state local to one page (whether a modal is open, say), a plain ref inside that component is enough — no need to drag a Store into it.

So Which One Do You Actually Pick?

Each of these four tools maps to a different "distance" and relationship between components. Work through them in this order:

  • Just handing a piece of data from parent to child? Use props.
  • Does the child need to tell the parent something happened? Use emit, paired with props for the full two-way conversation.
  • Does data need to cross several levels of the tree, without every level in between needing it? Use provide/inject to avoid prop drilling.
  • Are the two components on entirely different trees, or does the state need to be shared app-wide? Use a Store.

One mix-up that trips people up in practice is treating provide/inject and a Store as interchangeable, just because neither requires passing things down level by level. They're not. provide/inject is tied to the structure of the component tree — once the component that called provide unmounts, every inject beneath it loses access to that value. That makes it a good fit when a piece of data's lifecycle should match a specific subtree's. A Store, on the other hand, lives completely outside the component tree — as long as the app is running, the state is there, which is what makes it the right call for anything that genuinely needs to be shared globally and outlive any single page.

Wrapping Up

props, emit, provide/inject, and Store aren't competing solutions to the same problem — each one handles component communication at a different distance. props handles parent-to-child, emit handles child-to-parent, provide/inject handles cross-level communication that still stays within one component tree, and a Store handles state that's genuinely global. The question to ask isn't "which one is more convenient" — it's to first look at how the two components actually relate to each other, and let that tell you which tool fits. Get that right, and your data flow stays clear and predictable, and you won't spend your next debugging session grepping the entire project trying to figure out where a value actually came from.