Vue.js: Avoid Prop Drilling with Provide & Inject
February 13, 2024 • 3 min read
When building compound components in Vue, such as a Form with nested FormField components, it’s common to have state in the parent that every nested child needs to read, like validation errors or a submitting flag. Passing that state down as props through scoped slots or intermediate wrappers quickly gets noisy, a pattern often referred to as prop drilling. In this blog post, we will look at how Vue’s provide and inject can help us avoid it.
provideandinjectenable an ancestor component to share data with all of its descendants, regardless of how deep the component hierarchy is.
Let’s take a look at an example Form.vue component that holds the validation errors and a submitting flag:
<script setup>
import { ref } from 'vue'
const errors = ref({})
const isSubmitting = ref(false)
async function handleSubmit() {
/* submit the form and populate errors on failure */
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<slot :errors :isSubmitting />
</form>
</template>And a FormField.vue that needs to display the error for its own field and disable itself while the form is submitting:
<script setup>
defineProps({
name: { required: true, type: String },
label: { required: true, type: String },
errors: { required: true, type: Object },
isSubmitting: { required: true, type: Boolean },
})
</script>
<template>
<div>
<label>{{ label }}</label>
<input :name :disabled="isSubmitting">
<p v-if="errors[name]">{{ errors[name] }}</p>
</div>
</template>When we use these together, we end up passing errors and isSubmitting to every single field through a scoped slot:
<template>
<Form v-slot="{ errors, isSubmitting }">
<FormField name="email" label="Email" :errors :isSubmitting />
<FormField name="password" label="Password" :errors :isSubmitting />
<FormField name="name" label="Name" :errors :isSubmitting />
</Form>
</template>As you can see in the code above, every FormField has to receive the same two props even though the Form already owns that state. The moment we add another shared piece of state (like a touched map or a reset function), we have to thread it through every field again.
Using provide & inject
Let’s refactor Form.vue to provide its state to all descendants:
<script setup>
import { provide, ref } from 'vue'
const errors = ref({})
const isSubmitting = ref(false)
async function handleSubmit() {
/* submit the form and populate errors on failure */
}
provide('form', { errors, isSubmitting })
</script>
<template>
<form @submit.prevent="handleSubmit">
<slot />
</form>
</template>Now, in FormField.vue, we’ll use inject to retrieve the form state directly:
<script setup>
import { inject } from 'vue'
defineProps({
name: { required: true, type: String },
label: { required: true, type: String },
})
const { errors, isSubmitting } = inject('form')
</script>
<template>
<div>
<label>{{ label }}</label>
<input :name :disabled="isSubmitting">
<p v-if="errors[name]">{{ errors[name] }}</p>
</div>
</template>And the usage becomes a lot cleaner:
<template>
<Form>
<FormField name="email" label="Email" />
<FormField name="password" label="Password" />
<FormField name="name" label="Name" />
</Form>
</template>That’s it! Each FormField pulls the state it needs directly from its Form ancestor, and adding new shared state only requires changes in Form and the field that uses it.
Keeping the data reactive
One thing worth noting is that when you want descendants to react to changes, you need to provide the reactive source itself, not the unwrapped value.
provide('form', { errors, isSubmitting })
provide('form', { errors: errors.value, isSubmitting: isSubmitting.value }) If you provide .value, the injected data will be a plain, non-reactive snapshot, and fields won’t re-render when errors come back from the server.
Why not just use a composable?
A fair question is whether we could just use a useForm() composable instead. The answer depends on whether the state is global or scoped to a subtree.
- Use a composable (backed by a store like Pinia) when the data is global: the current user, auth token, locale, feature flags. There’s one instance per app.
- Use
provide/injectwhen the data is scoped to a specific ancestor instance. TwoFormcomponents on the same page must have their own independenterrorsandisSubmittingstate, so a shared global store doesn’t fit.
For compound components like Form / FormField, Tabs / TabPanel, or Modal / ModalClose, provide and inject are the natural fit.