Files
stack/packages/mosaic/framework/skills/vue-best-practices/reference/avoid-prop-drilling-use-provide-inject.md
T
fargo 1a822493ba format: apply repo prettier (3.8.1) to the folded skills tree
963 markdown files reformatted with the repository's pinned prettier so
pnpm format:check covers the folded tree like every other repo file.

The formatter's embedded-language pass also normalized code fences
(TS semicolons, closed HTML tags in examples, lowercased CSS hex colors,
one renumbered list that skipped an index). Alphanumeric token deltas vs
the fold commit were audited file-by-file; all are formatter-equivalent
markup normalizations plus the four sanitized skills.
2026-08-19 14:37:17 -05:00

6.0 KiB

title, impact, impactDescription, type, tags
title impact impactDescription type tags
Avoid Prop Drilling - Use Provide/Inject for Deep Component Trees MEDIUM Passing props through many layers creates maintenance burden and tight coupling between intermediate components best-practice
vue3
props
provide-inject
component-design
state-management
architecture

Avoid Prop Drilling - Use Provide/Inject for Deep Component Trees

Impact: MEDIUM - Prop drilling occurs when you pass props through multiple component layers just to reach a deeply nested child. This creates tight coupling, makes refactoring difficult, and clutters intermediate components with props they don't use.

Vue's provide/inject API allows ancestor components to share data with any descendant, regardless of nesting depth.

Task Checklist

  • Identify when props pass through 2+ intermediate components unchanged
  • Use provide/inject for data needed by deeply nested descendants
  • Use Pinia for global state shared across unrelated component trees
  • Keep props for direct parent-child relationships
  • Document provided values at the provider level

The Problem: Prop Drilling

<!-- App.vue -->
<template>
  <MainLayout :user="user" :theme="theme" :locale="locale" />
</template>
<!-- MainLayout.vue - Doesn't use these props, just passes them -->
<template>
  <Sidebar :user="user" :theme="theme" />
  <Content :user="user" :locale="locale" />
</template>
<!-- Sidebar.vue - Still drilling... -->
<template>
  <UserMenu :user="user" />
  <ThemeToggle :theme="theme" />
</template>
<!-- UserMenu.vue - Finally uses user prop -->
<template>
  <div>{{ user.name }}</div>
</template>

Problems:

  1. MainLayout and Sidebar are cluttered with props they don't use
  2. Adding a new shared value requires updating every component in the chain
  3. Removing a deeply nested component requires updating all ancestors
  4. Difficult to trace where data originates

Solution: Provide/Inject

Correct - Provider (ancestor):

<!-- App.vue -->
<script setup>
import { provide, ref, readonly } from 'vue';

const user = ref({ name: 'John', role: 'admin' });
const theme = ref('dark');
const locale = ref('en');

// Provide to all descendants
provide('user', readonly(user)); // readonly prevents mutations
provide('theme', theme);
provide('locale', locale);

// Provide update functions if needed
provide('updateTheme', (newTheme) => {
  theme.value = newTheme;
});
</script>

<template>
  <MainLayout />
</template>

Correct - Intermediate components are now clean:

<!-- MainLayout.vue - No props needed -->
<template>
  <Sidebar />
  <Content />
</template>
<!-- Sidebar.vue - No props needed -->
<template>
  <UserMenu />
  <ThemeToggle />
</template>

Correct - Consumer (descendant):

<!-- UserMenu.vue -->
<script setup>
import { inject } from 'vue';

// Inject from any ancestor
const user = inject('user');
</script>

<template>
  <div>{{ user.name }}</div>
</template>
<!-- ThemeToggle.vue -->
<script setup>
import { inject } from 'vue';

const theme = inject('theme');
const updateTheme = inject('updateTheme');

function toggleTheme() {
  updateTheme(theme.value === 'dark' ? 'light' : 'dark');
}
</script>

<template>
  <button @click="toggleTheme">Current: {{ theme }}</button>
</template>

Best Practices for Provide/Inject

1. Use Symbol Keys for Large Apps

Avoid string key collisions with symbols:

// keys.js
export const UserKey = Symbol('user');
export const ThemeKey = Symbol('theme');
<script setup>
import { provide } from 'vue';
import { UserKey, ThemeKey } from './keys';

provide(UserKey, user);
provide(ThemeKey, theme);
</script>
<script setup>
import { inject } from 'vue';
import { UserKey } from './keys';

const user = inject(UserKey);
</script>

2. Provide Default Values

Handle cases where no ancestor provides the value:

<script setup>
import { inject } from 'vue';

// With default value
const theme = inject('theme', 'light');

// With factory function for objects (avoids shared reference)
const config = inject('config', () => ({ debug: false }), true);
</script>

3. Use Readonly for Data Safety

Prevent descendants from mutating provided data:

<script setup>
import { provide, ref, readonly } from 'vue';

const user = ref({ name: 'John' });

// Descendants can read but not mutate
provide('user', readonly(user));

// Provide separate method for updates
provide('updateUser', (updates) => {
  Object.assign(user.value, updates);
});
</script>

4. Provide Computed Values for Reactivity

<script setup>
import { provide, computed } from 'vue';

const items = ref([1, 2, 3]);

// Descendants will reactively update
provide(
  'itemCount',
  computed(() => items.value.length),
);
</script>

When to Use What

Scenario Solution
Direct parent-child Props
1-2 levels deep Props (drilling is acceptable)
Deep nesting, same component tree Provide/Inject
Unrelated component trees Pinia (state management)
Cross-app global state Pinia
Plugin configuration Provide/Inject from plugin install

Provide/Inject vs Pinia

Provide/Inject:

  • Scoped to component subtree
  • Great for component library internals
  • No DevTools support
  • Ancestor-descendant relationships only

Pinia:

  • Global, accessible anywhere
  • Excellent DevTools integration
  • Better for application state
  • Works across unrelated components

Reference