Provide and inject
Assignment
Until part 6 brings a login, Martina Hostettler with the member number M-117 counts as the signed-in test member. The app should already make use of that.
As a signed-in member, I want my member number to be filled in on the form already, so that I don't have to type it every time.
The acceptance criteria are:
- The header shows who is signed in.
- The form fills in the member number of the signed-in member, and it stays editable.
- The components between
Appand the form know nothing about the member. - The dialog sits at the end of the page in the DOM and covers everything.
Learning goals
After this chapter you can
- explain when passing props down becomes tedious,
- provide values with
provideand get them withinject, - define keys for
provideandinjectin a file of their own, - apply the rules for the design of components,
- move content to another place on the page with
Teleport.
Passing props down
Section titled “Passing props down”App knows the signed-in member, and the form needs the member number. The page of the thing sits in between. With props, every component on the way has to take the member and pass it on, even if it does nothing with it itself. Look at how the member gets to the form in the example.
App.vue
<script setup>import ItemPage from './ItemPage.vue'
const member = { number: 'M-117', name: 'Martina Hostettler' }</script>
<template> <ItemPage :member="member" /></template>ItemPage.vue
<script setup>import ReservationForm from './ReservationForm.vue'
// ItemPage does not use member itself, it only passes it ondefineProps({ member: { type: Object, required: true } })</script>
<template> <h1>Drill</h1> <ReservationForm :member="member" /></template>ReservationForm.vue
<script setup>import { ref } from 'vue'
const props = defineProps({ member: { type: Object, required: true } })
const memberNumber = ref(props.member.number)</script>
<template> <label for="member">Member number</label> <input id="member" v-model="memberNumber"></template>ItemPage declares the prop member only to pass it on. With two levels that's still fine. With five levels, five components depend on a value only the last one needs, and a change to the member runs through all the files. This passing down is called prop drilling.
provide and inject
Section titled “provide and inject”With provide, a component makes a value available to all components below it. Each of them gets it with inject, no matter how deep it sits. The components in between notice nothing.
App.vue
<script setup>import { provide, readonly, ref } from 'vue'
import ItemPage from './ItemPage.vue'import { memberKey } from './keys.js'
const member = ref({ number: 'M-117', name: 'Martina Hostettler' })
// Every component below App can inject the member, readonly keeps the changes in Appprovide(memberKey, readonly(member))</script>
<template> <ItemPage /></template>ItemPage.vue
<script setup>import ReservationForm from './ReservationForm.vue'</script>
<template> <h1>Drill</h1> <ReservationForm /></template>keys.js
// A symbol as the key, so no other provide can use the same name by accidentexport const memberKey = Symbol('member')ReservationForm.vue
<script setup>import { inject, ref } from 'vue'
import { memberKey } from './keys.js'
// Without a provider, inject returns the default value nullconst member = inject(memberKey, null)const memberNumber = ref(member?.value.number ?? '')</script>
<template> <p v-if="member">Signed in as {{ member.name }}</p> <label for="member">Member number</label> <input id="member" v-model="memberNumber"></template>The key is a Symbol from the file keys.js. Two symbols are never equal, even with the same description. That way no other provide can overwrite the value by accident, and whoever needs the value imports the key from the same file. The second argument of inject is the default value, in case no component above provides the value.
App provides a ref, so the value stays reactive. If the member changes, for example after the login in part 6, all components see the new value. readonly makes sure that only App can change it. If a component further down should be allowed to change something, App also provides a function, for signing out for example, and so keeps control over the value.
Rules for components
Section titled “Rules for components”With props, events, slots and provide, you now know all the ways in which components work together. A few rules help to choose the right one.
- Props go down, events go up. Every value has one component that owns it and changes it.
- Presentational components like
ItemCard,SearchBarandBaseDialogonly show what they get and report what happens. Container components likeApp,CatalogViewandItemViewown data and decide. Presentational components can be reused and are easy to test. - Interfaces stay small. A component gets only the values it needs and knows only the events it really emits.
provideandinjectfit values that many components deep in the tree need, such as the signed-in member, the language or a color scheme. Between parent and child, props stay clearer. For state that many parts of the app change, part 7 brings Pinia.
Teleport
Section titled “Teleport”The dialog sits in the template of the form, so in the DOM it sits in the middle of the page. If an ancestor has overflow: hidden or transform, it clips the dialog or traps it, even with position: fixed. <Teleport to="body"> renders the content at the end of body instead. For Vue it stays part of the component, and props, events and inject work as before.
<script setup>import { ref } from 'vue'
const open = ref(false)const teleported = ref(true)</script>
<template> <label><input v-model="teleported" type="checkbox"> With Teleport</label> <div class="card"> <button type="button" @click="open = true">Open dialog</button> <!-- disabled keeps the content in place, the checkbox switches it --> <Teleport to="body" :disabled="!teleported"> <div v-if="open" class="backdrop" @click.self="open = false"> <div class="dialog" role="dialog" aria-modal="true" aria-label="Notice"> <p>The dialog lies over the whole page.</p> <button type="button" @click="open = false">Close</button> </div> </div> </Teleport> </div></template>
<style>/* A card that clips its content, as cards often do */.card { overflow: hidden; height: 6rem; margin-top: 0.5rem; padding: 0.5rem; border: 1px solid #c8c8c8; transform: translateZ(0);}.backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 40%);}.dialog { padding: 1rem; border-radius: 8px; background: #ffffff;}</style>Without Teleport, the dialog stays inside the card and gets clipped, with Teleport it lies over the whole page. The target must already exist when rendering. body is always there, while an element of your own like <div id="modals"> has to be in index.html.
Deep dive (optional) Dynamic components and KeepAlive
<component :is="current"> renders the component stored in current. Changing the value swaps the component. <KeepAlive> around it keeps the component that isn't shown, together with its state, instead of destroying it.
App.vue
<script setup>import { shallowRef } from 'vue'
import NoteTab from './NoteTab.vue'import RulesTab from './RulesTab.vue'
// shallowRef, because a component itself should not become reactiveconst current = shallowRef(NoteTab)</script>
<template> <button type="button" @click="current = NoteTab">Note</button> <button type="button" @click="current = RulesTab">Rules</button> <!-- KeepAlive keeps the component that is not shown, with its state --> <KeepAlive> <component :is="current" /> </KeepAlive></template>NoteTab.vue
<script setup>import { ref } from 'vue'
const note = ref('')</script>
<template> <p> <label for="note">Note on the request</label> <textarea id="note" v-model="note"></textarea> </p></template>RulesTab.vue
<template> <p>A loan lasts at most seven days.</p></template>Type a note, switch to the rules and back, and the note is still there. Without KeepAlive it's gone. shallowRef holds the component without making it reactive itself. In part 6, Vue Router renders the pages on the same principle, and KeepAlive can be used there as well. The case app keeps the search text in App instead, because that keeps it visible where it lives.
In code
Section titled “In code”Work with the example on provide and inject. Read the code and write down what you expect before you change anything.
- What does the form show? Where do the name and the number come from?
- Comment out
provideinApp. What does the form show now, and why is there no warning? - Also remove the default value
nullininject. What does the console report? - Switch to the example with Teleport, uncheck "With Teleport" and open the dialog. Where does it sit in the DOM now?
Exercises
Section titled “Exercises”The exercises pass the test member through the tree and move the dialog. You find the project in the course repo in the folder exercises/04-components/provide-inject/en. Copy the folder, install the packages and run the tests. The key memberKey is already in src/keys.js.
npm installnpm testExercise Getting started
In App.vue, provide the member with provide and the key memberKey, read-only with readonly. AppHeader gets it with inject and the default value null and shows "Signed in as Martina Hostettler" in an element with the class member. Without a member, the line is missing.
Solution
// The test member until the login of part 6const member = ref({ number: 'M-117', name: 'Martina Hostettler' })
// Every component below App can inject the member, readonly keeps the changes in Appprovide(memberKey, readonly(member))<script setup>import { inject } from 'vue'
import { memberKey } from '@/keys.js'
// Without a provider, inject returns the default value nullconst member = inject(memberKey, null)</script>
<template> <header class="site"> <a href="#/" class="brand">Lorraine Commons</a> <p v-if="member" class="member">Signed in as {{ member.name }}</p> </header></template>In the template Vue unwraps the ref, so member.name is enough. Without App above it, in a test for example, inject returns the default value null, and v-if leaves out the line.
Exercise Going further
ReservationForm also gets the member with inject and fills in the field for the member number with it. Without a member, the field stays empty. ItemPage gets no prop for the member.
Solution
const member = inject(memberKey, null)// The field starts with the number of the signed-in member and stays editableconst memberNumber = ref(member?.value.number ?? '')member?.value.number reads the number if there is a member, otherwise ?? '' steps in. The field only gets its initial value, after that its content belongs to the form. A member reserving for someone else can therefore change the number.
Exercise Challenge
The dialog after sending should sit at the end of the page in the DOM, not inside the form. Change BaseDialog so that it moves its content to the end of body with Teleport. The dialog still shows the thing and the member number and can be closed.
Solution
<template> <!-- The dialog leaves the component and ends up at the end of the page --> <Teleport to="body"> <div class="backdrop"> <div class="dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId"> <h2 :id="titleId"> <slot name="title">Notice</slot> </h2> <slot /> <div class="actions"> <slot name="actions"> <button type="button" @click="$emit('close')">Close</button> </slot> </div> </div> </div> </Teleport></template>Because Teleport sits in BaseDialog, every dialog of the app ends up at the end of the page without the forms knowing about it. Slots, props and the event close work as before, because for Vue the content stays part of the component.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
inject sits in an event handler, and Vue warns that it only works during setup. | Call inject at the top of <script setup> and keep the value in a constant. |
| Two parts of the app use the same text as a key and overwrite each other. | Define keys as a Symbol in a file of their own. |
| A change of the provided value doesn't arrive further down. | Provide a ref or a reactive object, not a plain value. |
| A component far down changes the provided value, and nobody knows where the change comes from. | Provide the value with readonly and add a function for changing it. |
Vue reports Failed to locate Teleport target with selector "#modals". | The target must exist when rendering. Use body or add the element to index.html. |
An AI assistant stores the member on window or accesses it through $root. | Provide values for the whole tree with provide, and use Pinia later for widely shared state. |
Self-check
Section titled “Self-check”Self-check
- When are
provideandinjectworth it instead of props? - Why is a
Symbola good key? - How do you make sure that only
Appchanges the member? - What does
Teleportdo, and what stays the same?
Answers
- When a value is needed deep in the tree and the components in between would only pass it on.
- Two symbols are never equal, so no other
providecan use the same key by accident. Appprovides the member withreadonly. If someone further down should change it, they get a function fromApp.- The content ends up in another place in the DOM, such as the end of
body. For Vue it stays part of the component, with props, events, slots andinject.
Assignment
The app is now made of components, and each one has a clear task. One annoyance remains at the counter, though. "After a reload my favorites are gone, and the search always starts from scratch," a member says. That's what part 5 is about. Before that, use the competence grid to check whether you're ready for part 5.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.