Skip to main content

provide / inject and custom directives — passing values past the hierarchy, and touching the DOM

props travel one step, from parent to child. The v-model in chapter 10 and the slots in chapter 11 both addressed a direct child. To get something from an ancestor to a distant descendant, every component in between has to declare it as a prop and pass it straight through. This chapter covers two mechanisms. provide and inject pass values past the hierarchy, and custom directives attach DOM work that template notation cannot express to an element.

What you'll learn in this chapter​

  • The order in which inject searches for a value, and why that search excludes the component itself
  • State the condition for the default being used more precisely than "when it was not found"
  • Distinguish what InjectionKey<T> adds to type checking from what it does not
  • What state an element is in at the moment each of a custom directive's seven hooks is called
  • What actually triggers the update hooks, rather than "when the value changed"
  • How a directive arrives when attached to a component

Passing values past the hierarchy​

provide places a value that descendants can pick up, and inject picks it up. The key is a string or a Symbol. The official guide recommends putting Symbols in a dedicated file for large applications and for components others will use. It avoids collisions and carries the TypeScript type along with it.

keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
theme: Readonly<Ref<'light' | 'dark'>>
toggle: () => void
}

export const themeKey = Symbol('theme') as InjectionKey<ThemeContext>

The value and the means of changing it are gathered into one object. That keeps the correspondence in the type, rather than splitting it across two keys.

components/ThemeProvider.vue
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import { themeKey } from '../keys'

const theme = ref<'light' | 'dark'>('light')

function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide(themeKey, { theme: readonly(theme), toggle })
</script>

<template>
<div :class="theme">
<slot />
</div>
</template>
components/ThemeButton.vue
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey } from '../keys'

const ctx = inject(themeKey)
if (!ctx) throw new Error('no ancestor provides themeKey')

const { theme, toggle } = ctx
</script>

<template>
<button type="button" @click="toggle">
{{ theme === 'light' ? 'To dark' : 'To light' }}
</button>
</template>

The receiving side is written the same way no matter how many levels sit in between. Both provide and inject are called synchronously inside setup. A single component can call provide any number of times with different keys. Writing app.provide(themeKey, ...) places it across the whole app, and plugins that have no components of their own use that.

Where the value is looked up from​

inject walks up through the parents and returns the first key it finds. When several ancestors place the same key, the nearer one covers the further one. A component's provide also covers the same key from app.provide.

In the implementation, each component's provides is an object whose prototype is the parent's provides. An object of its own is created only when provide is called.

let provides = currentInstance.provides
const parentProvides = currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
provides = currentInstance.provides = Object.create(parentProvides)
}
provides[key] = value

A component that never calls provide keeps sharing the parent's object, so what it places is visible only to its own descendants. Siblings do not receive it.

The search starts from the parent's provides. That is why a component cannot read what it provided itself. If the parent placed the same key, you get the parent's value rather than your own. The exceptions are the root component and custom elements, which start from where app.provide places things.

What it walks is the parent chain where the component was mounted. A descendant passed through a slot in chapter 11 resolves against where it was placed, not against the component that wrote the content. Write a button as the content of <ThemeProvider> and it receives the value even though the side that wrote the button provides nothing.

What happens when it is not found​

With no such key, inject returns undefined and the development build warns injection "Symbol(theme)" not found.. Passing a default silences the warning. For a value that is expensive to build, passing a function with true as the third argument treats it as a factory.

The check goes like this.

if (provides && key in provides) {
return provides[key]
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance && instance.proxy)
: defaultValue
} else {
warn(`injection "${String(key)}" not found.`)
}

That settles the following.

  • The search is key in provides. It walks the prototype chain, so it reaches ancestors' values. At the same time, a key set with provide(key, undefined) counts as present. In that case undefined comes back, the default is not used, and no warning is issued.
  • Whether the default is used is decided by arguments.length > 1. Merely writing undefined as the second argument silences the warning. The value is still undefined.
  • It is treated as a factory only when the third argument is truthy and the default is a function. Forget the true and the function itself lands as the value.

So "the default is insurance against there being no provider" is not enough of an understanding. It looks only at whether the key exists, so a single provider that placed undefined makes the default pass by.

Calling inject outside setup gives a different warning.

warn(`inject() can only be used inside setup() or functional components.`)

Calling it inside an event handler falls here, and the return value is undefined. Inside app.runWithContext() is an exception, but that looks only at where app.provide places things. Even used inside setup, ancestors' provides are invisible there.

A ref arrives as-is​

If the provided value is a ref, it arrives unwrapped as-is. What descendants receive is a ref, so changing the value on the provider side re-renders the descendants.

Where to put the change is a design question. The official guide recommends keeping changes on the provider side, and offers the form of providing a mutation function alongside when descendants need to change it. The ThemeContext above holding theme and toggle as a pair is that form.

Wrapping it in readonly() goes further and blocks assignment from descendants.

provide(themeKey, { theme: readonly(theme), toggle })

A descendant writing theme.value = 'dark' does not change the value, and the development build warns target is readonly. The provider side holds the original theme, so toggle() can still change it.

The protection on the type side is limited, though. The type of readonly(ref(0)) is Readonly<Ref<number, number>>, and assigning to .value is a type error. That type is nevertheless assignable to a Ref<number> slot — the readonly modifier does not affect assignability. Pass it through a function or an interface field taking Ref<T> and the compile-time protection is gone. The runtime protection remains.

Lining up the types​

InjectionKey<T> is a utility type extending Symbol that ties the provide and inject types together.

How it is writtenReturn typeValue on the provide side
inject(themeKey)ThemeContext | undefinedA mismatched type is a type error
inject(themeKey, defaultValue)ThemeContextAs above
inject('theme')unknownAnything gets through
inject<ThemeContext>('theme')ThemeContext | undefinedAnything gets through

With a string key the two sides' types are not tied together. Writing a type argument is merely a declaration on the inject side; the provider passing a different type goes unchecked.

InjectionKey<T> has a hole of its own. The definition is this.

interface InjectionConstraint<T> {}
export type InjectionKey<T> = symbol & InjectionConstraint<T>

T is never used inside the type. As a result InjectionKey<string> and InjectionKey<number> are mutually assignable. What works is only the part where provide and inject extract T from the key's declared type. The Symbol() as InjectionKey<T> cast goes unchecked too, so the types cannot guarantee that the key declaration itself is right.

One more. The return of an inject given a default is T, with undefined removed, but there is still a route where undefined comes back at runtime — the provide(key, undefined) from the previous section.

Touching the DOM from the template​

Whatever can be written declaratively with a built-in directive is faster written that way and suits server-side rendering better. Custom directives are the tool for when direct DOM manipulation is the only option: focusing an element, measuring its dimensions, and the like. In <script setup>, a camelCase variable starting with v becomes a directive directly.

components/SearchBox.vue
<script setup lang="ts">
import type { Directive } from 'vue'
import { vHighlight } from '../directives/highlight'

const vFocus: Directive<HTMLInputElement> = {
mounted: (el) => el.focus(),
}
</script>

<template>
<label>
<span v-highlight:text.bold="'crimson'">Keyword</span>
<input v-focus type="search" />
</label>
</template>

That correspondence is settled at compile time. v-focus is replaced with a direct reference to vFocus (_hoisted_1 gathers the static attributes).

_withDirectives(_createElementVNode("input", _hoisted_1, null, 512 /* NEED_PATCH */), [
[vFocus]
])

Without <script setup>, or with global registration through app.directive(), it becomes a runtime lookup.

const _directive_focus = _resolveDirective("focus")

Failing to resolve warns Failed to resolve directive: focus, and that directive simply does not happen. The element still renders normally, so a misspelt name is a failure you will not notice by looking.

There are restrictions on where they can go. Attaching one to a <slot> outlet from chapter 11 stops the build with Unexpected custom directive on <slot> outlet.. Only directives that need a runtime directive trip this check, so v-if / v-bind / v-on get through, while v-show stops with the same message despite being built in.

Hooks and binding​

There are seven hooks corresponding to the DOM lifetime, all optional: created / beforeMount / mounted / beforeUpdate / updated / beforeUnmount / unmounted. The measured order comes out like this.

PhaseOrder
Mountcreated → beforeMount → mounted → that component's onMounted
UpdatebeforeUpdate → updated → that component's onUpdated
Unmountthat component's onBeforeUnmount → beforeUnmount → unmounted → onUnmounted

mounted / updated / unmounted go into the post-flush queue. The onMounted / onUpdated / onUnmounted from chapter 7 go into the same queue, but the element-side hooks are queued first and therefore run first.

For the three hooks that fire when rendering fresh on the client, the element is in a different state at each.

HookAttributes appliedInserted into the parent nodeChildren
createdNoNoPresent
beforeMountYesNoPresent
mountedYesYesPresent

created is "before attributes are applied", but the children are already mounted. The exception is multiple on <select multiple>, which alone is applied as the element is created and is visible from created. Work that only takes effect once the element is in the document, such as el.focus(), goes in mounted. Under hydration this table is reversed — the element the server emitted is reused, so attributes and insertion are already done at created.

On the unmount side, the element is still in its parent node at beforeUnmount and detached at unmounted. While cached in a <KeepAlive>, neither hook is called when it is hidden — only onDeactivated runs. On the route where max pushes it out it is unmounted and they are called, but the element has been moved to the stash and is not connected to the document. The differences per route are gathered in chapter 7.

The binding in the second parameter holds only these.

PropertyContents
valueThe value of the expression passed to the directive
oldValueThe previous value. undefined on mount, filled in on update (even when the value did not change). If an update preceded it, it survives into the unmount-side hooks too
argThe text in v-highlight:text. Writing :[expr] makes it dynamic
modifiers.bold turned into { bold: true }
instanceThe component that wrote the directive
dirThe directive definition object itself

Treat everything but el as read-only. To pass information between hooks, the official guide recommends using the element's dataset.

To constrain the argument and modifiers with types, use Directive's type parameters. The order is element / value / modifiers / argument.

directives/highlight.ts
import type { Directive, DirectiveBinding } from 'vue'

export type HighlightDirective = Directive<HTMLElement, string, 'bold', 'bg' | 'text'>

type HighlightBinding = DirectiveBinding<string, 'bold', 'bg' | 'text'>

function apply(el: HTMLElement, binding: HighlightBinding) {
const target = binding.arg === 'text' ? 'color' : 'backgroundColor'
el.style[target] = binding.value
el.style.fontWeight = binding.modifiers.bold ? 'bold' : ''
}

export const vHighlight: HighlightDirective = {
mounted: apply,
updated: apply,
}

When mounted and updated do the same thing, you can write a single function instead of an object. That form is normalised to { mounted: fn, updated: fn }, so it has none of the other five hooks.

Type checking catches mismatches against the declaration; it does not look at the correspondence between the name and the element.

What you wrotevue-tsc
A value type differing from the declarationType error
An argument not in the declarationType error
A modifier not in the declarationType error
An unregistered name (v-nope)Passes
An element not matching the declaration (Directive<HTMLInputElement> on a <div>)Passes

To make types apply to globally registered directives, extend the GlobalDirectives interface. Note that registering one under the same name as a built-in directive warns Do not use built-in directive ids as custom directive id. Both app.directive() and the directives option are checked.

What triggers the update hooks​

beforeUpdate / updated are called at the moment that element is patched. Whether the directive's value changed is not part of the condition. Change an unrelated ref in the same template and both hooks are called with oldValue and value holding the same value.

To keep the element from slipping out of patching, the compiler makes two provisions. Elements with children are promoted to blocks. A comment in the implementation gives the reason: custom directives may use beforeUpdate, so a block is forced to allow calling it before the children update. The patch flag is irrelevant.

Elements that do not become blocks get a patch flag. The condition is that the patch flag is only 0 or 32. 32 is a hydration marker and does not, on its own, get the node collected as a dynamic child.

const needsPatch =
(patchFlag === 0 || patchFlag === 32) &&
(hasRef || hasVnodeHook || runtimeDirectives.length > 0)
if (!shouldUseBlock && needsPatch) {
patchFlag |= 512
}

They are also excluded from static hoisting. Put one alongside v-once, though, and the vnode is reused, so no patch happens and no update hooks arrive. v-memo does the same for a re-render where the dependencies are unchanged, and they do arrive once the dependencies change.

Reading inside the hooks has a constraint. The calls are made with tracking paused.

pauseTracking()
callWithAsyncErrorHandling(hook, instance, 8, [vnode.el, binding, vnode, prevVNode])
resetTracking()

So reading a reactive value inside a hook does not make it a dependency. Changing that value does not re-render the enclosing component. Pass values you want to react to as the directive's value. Even then, overwriting only the contents of a passed object brings no update hooks unless the enclosing template reads it. Writing deep: true in the definition walks the value and takes it as a dependency.

Under server-side rendering, none of the DOM-touching hooks are called. The only thing that runs is getSSRProps, and the object it returns is emitted as attributes in the HTML. Work that only means something in the browser is the same judgement as the composables in chapter 9.

When you attach it to a component​

Write <MyComponent v-focus /> and the directive is moved to the root node of that component's render output. The official guide does not recommend this usage, because where it lands depends on the render output.

The check has two stages. The hooks run only when the root is an element. A warning is issued when the root is neither an element, a component, nor a comment. The four measured shapes came out like this.

Root nodeResult
A single elementReaches that element
Another componentDescends to the root element below it
Multiple roots (Fragment)A warning is issued and no hook is called
A comment node from a false v-ifNo warning and no hook is called. Once it turns true it runs from created

The warning for multiple roots is Runtime directive used on component with non-element root node.. Unlike attributes, it cannot be forwarded to another element with v-bind="$attrs".

binding.instance points at the component that wrote the directive, not the one that rendered the element it ended up on. The same holds when written in slot content from chapter 11: it points at the parent that wrote the content.

Summary​

  • provide places a value descendants can pick up, and inject walks up the parents and returns the first key it finds. Keys are strings or Symbols, and the official guide recommends putting Symbols in a dedicated file. The search starts from the parent, so a component cannot read what it provided itself (only the root and custom elements start from where app.provide places things, and inside app.runWithContext() only that is consulted)
  • Each component's provides is an object prototyped on the parent's, and it gets one of its own when provide is called. What it places is visible only to its descendants, never to siblings
  • What it walks is the parent chain where the component was mounted. A descendant passed as slot content resolves against where it was placed
  • The search is key in provides. A key set with provide(key, undefined) counts as present, so the default is not used and no warning is issued. Whether the default is used is decided by arguments.length > 1, so merely writing undefined as the second argument silences the warning. It is a factory only when the third argument is truthy and the default is a function; forget the true and the function itself becomes the value
  • A provided ref arrives unwrapped as-is. The official guide recommends keeping changes on the provider side, and wrapping in readonly() reduces descendant assignment to a warning. The type protection stops at assignment to .value, though, and is lost once it passes into a Ref<T> slot
  • InjectionKey<T>'s T is never used inside the type, so different Ts are mutually assignable. What works is only where provide / inject extract T from the key's declared type. String keys tie neither side together
  • Custom directives are the tool for when direct DOM manipulation is the only option. The v prefix in <script setup> resolves at compile time; everything else becomes a runtime resolveDirective. Failing to resolve warns, and that directive simply does not happen
  • There are seven hooks for the DOM lifetime. The order is created → beforeMount → mounted, and they run before the component's onMounted. created is before attributes are applied, but the children are already mounted. While cached in <KeepAlive> the two unmount-side hooks are not called; they are when it is pushed out
  • beforeUpdate / updated are called at the moment the element is patched, not conditioned on the value changing. To avoid missing it, elements with children are promoted to blocks, and elements that do not become blocks get 512 added when the patch flag is only 0 or 32. With v-once, and with a v-memo whose dependencies are unchanged, no patch happens at all
  • Hook calls are made with tracking paused, so reading a reactive value inside does not make it a dependency. Pass values you want to react to as value. Under server-side rendering the DOM-touching hooks are not called and only getSSRProps runs
  • Attached to a component, it moves to the root node of the render output. The hooks run only when the root is an element; a component root descends further, multiple roots warn and call nothing, and a comment from a false v-if calls nothing with no warning
  • binding.instance points at the component that wrote the directive, not the one that rendered the element it landed on
Related references