Skip to content

Solving Prop Drilling in Vue: Modern State Management Strategies

Published: at 

TL;DR: Prop Drilling Solutions at a Glance

Click the toggle button to see interactive diagram animations that demonstrate each concept.


The Hidden Cost of Prop Drilling: A Real-World Scenario

Imagine building a Vue dashboard where the user’s name needs to be displayed in seven nested components. Every intermediate component becomes a middleman for data it doesn’t need. Imagine changing the prop name from userName to displayName. You’d have to update six components to pass along something they don’t use!

Current User: Guest
Parent
Provides username to Child
username: Guest
Child
Passes username to Grandchild
username: Guest
Grandchild
Uses username prop
username: Guest

This is prop drilling – and it creates:


Solution 1: Pinia for Global State Management

When to Use: App-wide state (user data, auth state, cart items)

Current User: Guest
User Store
// stores/user.js
export const useUserStore = defineStore('user', {
username: ref('Guest'),
setUsername: (name) => username.value = name
});
Parent
useUserStore()
username: Guest
Child
useUserStore()
username: Guest
Grandchild
useUserStore()
username: Guest

Implementation:

// stores/user.js
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
const username = ref(localStorage.getItem('username') || 'Guest');
const isLoggedIn = computed(() => username.value !== 'Guest');
  
function setUsername(newUsername) {
    username.value = newUsername;
    localStorage.setItem('username', newUsername);
}

return {
    username,
    isLoggedIn,
    setUsername
};
});

Component Usage:

<!-- DeeplyNestedComponent.vue -->
<script setup>
import { import useUserStoreuseUserStore } from '@/stores/user';
const const user: anyuser = import useUserStoreuseUserStore();
</script>

<template>
  <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="user-info">
    Welcome, {{ const user: anyuser.username }}!
    <button: ButtonHTMLAttributes & ReservedPropsbutton v-if="!const user: anyuser.isLoggedIn" @onClick?: ((payload: MouseEvent) => void) | undefinedclick="const user: anyuser.setUsername('John')">
      Log In
    </button: ButtonHTMLAttributes & ReservedPropsbutton>
  </div: HTMLAttributes & ReservedPropsdiv>
</template>

Pros

⚠️ Cons


Solution 2: Composables for Reusable Logic

When to Use: Shared component logic (user preferences, form state)

Current User: Guest
useUser Composable
const username = ref('Guest');
return { username, setUsername };
Parent
const { username } = useUser()
username: Guest
Child
const { username } = useUser()
username: Guest
Grandchild
const { username } = useUser()
username: Guest

Implementation with TypeScript:

// composables/useUser.ts
import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
} from 'vue';
const const username: Ref<string, string>username = ref<string>(value: string): Ref<string, string> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(var localStorage: Storage
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/localStorage) A browser-compatible implementation of [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). Data is stored unencrypted in the file specified by the `--localstorage-file` CLI flag. The maximum amount of data that can be stored is 10 MB. Any modification of this data outside of the Web Storage API is not supported. Enable this API with the `--experimental-webstorage` CLI flag. `localStorage` data is not stored per user or per request when used in the context of a server, it is shared across all users and requests.
@sincev22.4.0
localStorage
.Storage.getItem(key: string): string | null
Returns the current value associated with the given key, or null if the given key does not exist. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem)
getItem
('username') || 'Guest');
export function
function useUser(): {
    username: Ref<string, string>;
    setUsername: (newUsername: string) => void;
}
useUser
() {
const const setUsername: (newUsername: string) => voidsetUsername = (newUsername: stringnewUsername: string) => { const username: Ref<string, string>username.Ref<string, string>.value: stringvalue = newUsername: stringnewUsername; var localStorage: Storage
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/localStorage) A browser-compatible implementation of [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). Data is stored unencrypted in the file specified by the `--localstorage-file` CLI flag. The maximum amount of data that can be stored is 10 MB. Any modification of this data outside of the Web Storage API is not supported. Enable this API with the `--experimental-webstorage` CLI flag. `localStorage` data is not stored per user or per request when used in the context of a server, it is shared across all users and requests.
@sincev22.4.0
localStorage
.Storage.setItem(key: string, value: string): void
Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously. Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) Dispatches a storage event on Window objects holding an equivalent Storage object. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem)
setItem
('username', newUsername: stringnewUsername);
}; return { username: Ref<string, string>username, setUsername: (newUsername: string) => voidsetUsername, }; }

Component Usage:


<!-- UserProfile.vue -->
<script setup lang="ts">
const { const username: anyusername, const setUsername: anysetUsername } = useUser();
</script>

<template>
  <div: HTMLAttributes & ReservedPropsdiv HTMLAttributes.class?: anyclass="user-profile">
    <h2: HTMLAttributes & ReservedPropsh2>Welcome, {{ const username: anyusername }}!</h2: HTMLAttributes & ReservedPropsh2>
    <button: ButtonHTMLAttributes & ReservedPropsbutton @onClick?: ((payload: MouseEvent) => void) | undefinedclick="const setUsername: anysetUsername('John')">
      Update Username
    </button: ButtonHTMLAttributes & ReservedPropsbutton>
  </div: HTMLAttributes & ReservedPropsdiv>
</template>

Pros

⚠️ Cons

Solution 3: Provide/Inject for Component Tree Scoping

When to Use: Library components or feature-specific user data

Current User: Guest
Parent Provider
import { provide, ref } from 'vue';
const username = ref('Guest');
provide(USER_KEY, username);
Symbol Key
const USER_KEY = Symbol('user');
Parent (Provider)
provide(USER_KEY, username)
username: Guest
Child (No Injection)
Passes username implicitly
Grandchild (Injector)
const username = inject(USER_KEY)
username: Guest

Type-Safe Implementation:


// utilities/user.ts
import type { type InjectionKey<T> = symbol & InjectionConstraint<T>InjectionKey } from 'vue';

interface UserContext {
  UserContext.username: Ref<string>username: type Ref = /*unresolved*/ anyRef<string>;
  UserContext.updateUsername: (name: string) => voidupdateUsername: (name: stringname: string) => void;
}

export const const UserKey: InjectionKey<UserContext>UserKey = 
var Symbol: SymbolConstructor
(description?: string | number) => symbol
Returns a new unique Symbol value.
@paramdescription Description of the new Symbol object.
Symbol
('user') as type InjectionKey<T> = symbol & InjectionConstraint<T>InjectionKey<UserContext>;
// ParentComponent.vue <type script = /*unresolved*/ anyscript setup lang="ts"> import {
import UserKey
var UserKey: InjectionKey<UserContext>
UserKey
} from '@/utilities/user';
const const username: anyusername = ref<string>('Guest'); const const updateUsername: (name: string) => voidupdateUsername = (name: stringname: string) => { const username: anyusername.value = name: stringname; }; provide(
import UserKey
var UserKey: InjectionKey<UserContext>
UserKey
, { username: anyusername, updateUsername: (name: string) => voidupdateUsername });
</script> // DeepChildComponent.vue <script setup lang="ts"> import { import UserKeyUserKey } from '@/utilities/user'; const { const username: anyusername, const updateUsername: anyupdateUsername } = inject(
import UserKey
var UserKey: InjectionKey<UserContext>
UserKey
, {
username: anyusername: ref('Guest'), updateUsername: () => voidupdateUsername: () => var console: Console
The `console` module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers. The module exports two specific components: * A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream. * A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and [`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module. _**Warning**_: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for more information. Example using the global `console`: ```js console.log('hello world'); // Prints: hello world, to stdout console.log('hello %s', 'world'); // Prints: hello world, to stdout console.error(new Error('Whoops, something bad happened')); // Prints error message and stack trace to stderr: // Error: Whoops, something bad happened // at [eval]:5:15 // at Script.runInThisContext (node:vm:132:18) // at Object.runInThisContext (node:vm:309:38) // at node:internal/process/execution:77:19 // at [eval]-wrapper:6:22 // at evalScript (node:internal/process/execution:76:60) // at node:internal/main/eval_string:23:3 const name = 'Will Robinson'; console.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to stderr ``` Example using the `Console` class: ```js const out = getStreamSomehow(); const err = getStreamSomehow(); const myConsole = new console.Console(out, err); myConsole.log('hello world'); // Prints: hello world, to out myConsole.log('hello %s', 'world'); // Prints: hello world, to out myConsole.error(new Error('Whoops, something bad happened')); // Prints: [Error: Whoops, something bad happened], to err const name = 'Will Robinson'; myConsole.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to err ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)
The `console.warn()` function is an alias for {@link error } .
@sincev0.1.100
warn
('No user provider!'),
}); </script>

Pros

⚠️ Cons


Why Event Buses Fail for State Management

Current User: Guest
Event Bus (mitt)
const emitter = mitt();
// Events flying everywhere!
emitter.emit('userChange', username);
emitter.on('userChange', (name) => {...});
Header
emitter.emit('userChange', 'John')
username: Guest
Settings
emitter.on('userChange', updateUI)
username: Guest

Event buses create more problems than they solve for state management:

  1. Spaghetti Data Flow
    Components become invisibly coupled through arbitrary events. When ComponentA emits update-theme, who’s listening? Why? DevTools can’t help you track the chaos.

  2. State Inconsistencies
    Multiple components listening to the same event often maintain duplicate state:

    // Two components, two sources of truth
    eventBus.on('login', () => this.isLoggedIn = true)
    eventBus.on('login', () => this.userStatus = 'active')
    
  3. Memory Leaks
    Forgotten event listeners in unmounted components keep reacting to events, causing bugs and performance issues.

Where Event Buses Actually Work

Instead of Event Buses: Use Pinia for state, composables for logic, and provide/inject for component trees.

No

Yes

App-wide

Component Tree

Reusable Logic

Need Shared State?

Props/Events

Scope?

Pinia

Provide/Inject

Composables

Decision Guide: Choosing Your Weapon

Pro Tips for State Management Success

  1. Start Simple: Begin with props, graduate to composables
  2. Type Everything: Use TypeScript for stores/injections
  3. Name Wisely: Prefix stores (useUserStore) and injection keys (UserKey)
  4. Monitor Performance: Use Vue DevTools to track reactivity
  5. Test State: Write unit tests for Pinia stores/composables

By mastering these patterns, you’ll write Vue apps that scale gracefully while keeping component relationships clear and maintainable.

Related Posts