Refactoring Prop Drilling with Provide and Inject in Vue 3
In this guide, we'll explore how to refactor prop drilling in a Vue 3 application using the provide and inject API. We'll start with an example that uses local data and prop drilling to pass data through multiple component layers, identify its drawbacks, and then refactor it using provide and inject. We'll also show how to handle fetch requests and share fetched data across components.
What is Prop Drilling?
Prop drilling refers to the process of passing data from a parent component down to deeply nested child components through multiple layers of intermediate components. This can make your code:
- Hard to maintain.
- Prone to errors.
- Cluttered with unnecessary props.
The Problem with Prop Drilling (Using Local Data)
Let's consider a movie app where the App
component holds a list of movies (local data), and we need to display this data in a deeply nested MovieItem
component.
Initial Setup with Prop Drilling
First, we'll set up our components using local data.
App.vue
<template>
<div id="app">
<MovieList :movies="movies" />
</div>
</template>
<script setup lang="ts">
import { defineComponent } from 'vue';
import MovieList from './components/MovieList.vue';
const movies = [
{ id: 1, title: 'Inception', description: 'A mind-bending thriller.' },
{ id: 2, title: 'The Matrix', description: 'A cyberpunk classic.' },
{ id: 3, title: 'Interstellar', description: 'A journey through space and time.' },
];
</script>
MovieList.vue
<template>
<div>
<h2>Movie List</h2>
<ul>
<MovieItem v-for="movie in movies" :key="movie.id" :movie="movie" />
</ul>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import MovieItem from './MovieItem.vue';
const props = defineProps<{
movies: Array<{ id: number; title: string; description: string }>;
}>();
</script>
MovieItem.vue
<template>
<li>
{{ movie.title }}
</li>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps<{
movie: { id: number; title: string; description: string };
}>();
</script>
In this example:
App.vue
holds themovies
data and passes it toMovieList.vue
via themovies
prop.MovieList.vue
receives themovies
prop and passes eachmovie
toMovieItem.vue
via themovie
prop.
Adding a Theme Prop
Now, suppose we want to add a theme (e.g., 'light' or 'dark') that affects the styling of MovieItem.vue
. We need to pass the theme
prop through each component:
Updated App.vue
<template>
<div id="app">
<MovieList :movies="movies" :theme="theme" />
</div>
</template>
<script setup lang="ts">
import MovieList from './components/MovieList.vue';
const movies = [
{ id: 1, title: 'Inception', description: 'A mind-bending thriller.' },
{ id: 2, title: 'The Matrix', description: 'A cyberpunk classic.' },
{ id: 3, title: 'Interstellar', description: 'A journey through space and time.' },
];
const theme = 'light';
</script>
Updated MovieList.vue
<template>
<div>
<h2>Movie List</h2>
<ul>
<MovieItem v-for="movie in movies" :key="movie.id" :movie="movie" :theme="theme" />
</ul>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import MovieItem from './MovieItem.vue';
const props = defineProps<{
movies: Array<{ id: number; title: string; description: string }>;
theme: string;
}>();
</script>
Updated MovieItem.vue
<template>
<li :class="theme">
{{ movie.title }}
</li>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps<{
movie: { id: number; title: string; description: string };
theme: string;
}>();
</script>
<style>
.light {
color: black;
}
.dark {
color: white;
background-color: black;
}
</style>
Pitfall
Passing down props through multiple layers can lead to bloated components and increased maintenance overhead.
Refactoring with Provide and Inject (Using Local Data)
We can simplify data sharing by using the provide and inject API, eliminating the need to pass props through every component layer.
Step 1: Provide Data in Ancestor Component
In App.vue
, we use the provide
function to supply the theme
and movies
data.
Updated App.vue
<template>
<div id="app">
<MovieList />
</div>
</template>
<script setup lang="ts">
import { provide } from 'vue';
import MovieList from './components/MovieList.vue';
const movies = [
{ id: 1, title: 'Inception', description: 'A mind-bending thriller.' },
{ id: 2, title: 'The Matrix', description: 'A cyberpunk classic.' },
{ id: 3, title: 'Interstellar', description: 'A journey through space and time.' },
];
const theme = 'light';
provide('movies', movies);
provide('theme', theme);
</script>
- We remove the
:movies
and:theme
props fromMovieList
. - We provide
movies
andtheme
usingprovide
.
Step 2: Remove Unnecessary Props from Intermediate Component
MovieList.vue
no longer needs to accept movies
and theme
as props.
Updated MovieList.vue
<template>
<div>
<h2>Movie List</h2>
<ul>
<MovieItem v-for="movie in movies" :key="movie.id" :movie="movie" />
</ul>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import MovieItem from './MovieItem.vue';
const movies = inject<{ id: number; title: string; description: string }[]>('movies');
if (!movies) {
throw new Error('movies not provided');
}
</script>
- We inject
movies
directly usinginject('movies')
. - We remove the
movies
andtheme
props definitions.
TIP
By injecting movies
, we simplify MovieList.vue
and avoid unnecessary prop passing.
Step 3: Inject Data in Descendant Component
In MovieItem.vue
, we inject theme
directly.
Updated MovieItem.vue
<template>
<li :class="theme">
{{ movie.title }}
</li>
</template>
<script setup lang="ts">
import { defineProps, inject } from 'vue';
const props = defineProps<{
movie: { id: number; title: string; description: string };
}>();
const theme = inject<string>('theme', 'light');
</script>
<style>
.light {
color: black;
}
.dark {
color: white;
background-color: black;
}
</style>
- We inject
theme
usinginject('theme', 'light')
, with a default value of 'light'.
Do's and Don'ts
- Do use
provide
andinject
to simplify data sharing across components. - Don't overuse
provide
andinject
for data that can be passed via props between closely related components.
Handling Fetch Requests and Sharing Data
Now, let's introduce data fetching into our application. We'll fetch movies data from an API and share it across components.
Step 1: Fetch Data in the Provider Component
In App.vue
, we'll fetch movies data and provide it to descendant components.
Updated App.vue
(with fetch request)
<template>
<div id="app">
<MovieList />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, provide } from 'vue';
import MovieList from './components/MovieList.vue';
interface Movie {
id: number;
title: string;
description: string;
}
const movies = ref<Movie[]>([]);
const theme = ref('light');
provide('movies', movies);
provide('theme', theme);
onMounted(async () => {
try {
const response = await fetch('https://api.example.com/movies');
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
movies.value = await response.json();
} catch (err) {
console.error(err);
}
});
</script>
- We change
movies
to aref<Movie[]>([])
to make it reactive. - We fetch movies data in the
onMounted
lifecycle hook and updatemovies.value
. - Since
movies
is reactive and provided, any component injectingmovies
will react to changes.
Step 2: Update Components to Handle Reactive Data
Updated MovieList.vue
<template>
<div>
<h2>Movie List</h2>
<ul>
<MovieItem v-for="movie in movies" :key="movie.id" :movie="movie" />
</ul>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import MovieItem from './MovieItem.vue';
interface Movie {
id: number;
title: string;
description: string;
}
const movies = inject<Ref<Movie[]>>('movies');
if (!movies) {
throw new Error('movies not provided');
}
</script>
- Since
movies
is aref
, we declare its type asRef<Movie[]>
.
Updated MovieItem.vue
No changes needed; movie
is passed as a prop from MovieList.vue
.
Important
Ensure that reactive data is correctly typed when injected to maintain TypeScript support and prevent runtime errors.
Showing Pitfalls, Do's and Don'ts, Tips, and Important Notes
Pitfalls
Pitfall
Reactive Data Not Updating:
If you inject a reactive ref
but destructure it, you lose reactivity.
Solution: Use the injected ref
directly without destructuring.
// Incorrect
const movies = inject<Ref<Movie[]>>('movies');
const movieList = movies.value; // This breaks reactivity
// Correct
const movies = inject<Ref<Movie[]>>('movies');
Do's and Don'ts
Do's
- Do use
provide
andinject
to simplify data sharing across non-parent-child components. - Do wrap primitive values with
ref
when you need reactivity. - Do use TypeScript interfaces to define types for your data.
Don'ts
- Don't overuse
provide
andinject
for simple parent-child prop passing. - Don't forget to handle cases where the injected value might be
undefined
. - Don't mutate injected properties directly if they are not reactive.
Tips
TIP
Use Symbols for Keys: Using symbols as keys when providing and injecting prevents naming collisions.
ts// Define a symbol const MoviesSymbol = Symbol('movies'); // Provide provide(MoviesSymbol, movies); // Inject const movies = inject<Ref<Movie[]>>(MoviesSymbol);
Default Values: When injecting, you can provide a default value.
tsconst theme = inject<string>('theme', 'light');
Important Notes
Important
Type Safety with TypeScript: Always specify types when using
provide
andinject
to leverage TypeScript's type checking.ts// Provide provide('movies', movies); // Inject with type const movies = inject<Ref<Movie[]>>('movies');
Lifecycle Hooks: Ensure that data is provided before components that inject it are mounted.
Best Practices
1. Use Provide and Inject for Cross-Cutting Concerns
Ideal for data or services that are needed by many components at different levels:
- Theme settings
- Localization
- User authentication status
2. Avoid Overusing Provide and Inject
While powerful, overusing can make your code harder to follow. Use when:
- Prop drilling becomes unmanageable.
- The data is truly global or widely used.
3. Keep Reactive Data Reactive
Always wrap your data with ref
or reactive
when providing if you need reactivity.
4. Handle Injection Failures
Always check if the injected value exists and handle cases where it doesn't.
const value = inject('key');
if (!value) {
throw new Error('key not provided');
}
Pitfalls
Pitfall 1: Injecting Non-Reactive Data
Issue: Providing a primitive value directly (e.g., a string) doesn't maintain reactivity.
Solution: Wrap primitive values with ref
when providing.
// Provide
const theme = ref('light');
provide('theme', theme);
// Inject
const theme = inject<Ref<string>>('theme');
Pitfall 2: Components Rendering Before Provide
Issue: A component tries to inject a value before it's provided.
Solution: Ensure that the provider component is an ancestor in the component tree and is rendered before the injecting component.
Pitfall 3: Overwriting Injected Values
Issue: Accidentally mutating an injected value can cause unexpected behavior.
Solution: Treat injected values as read-only unless intentionally shared for mutation.
Conclusion
By starting with local data and then handling fetch requests, we've demonstrated how to:
- Refactor prop drilling with provide and inject in Vue 3.
- Simplify data sharing across components without passing props through every level.
- Use practical examples to manage themes and shared data.
- Handle data fetching and share fetched data across components efficiently.
- Correctly use
defineProps
with proper TypeScript types.
Remember: Provide and inject are powerful tools that, when used appropriately, can greatly enhance the scalability and cleanliness of your Vue applications.
Happy Coding! If you have any questions or need further assistance, feel free to ask.