Post contents
In our last chapter, we talked about how you can create custom logic that is not associated with any particular component but can be used by said components to extend its logic.
This is helpful for sharing logic between components, but isn't the whole story of code reuse within React, Angular, and Vue.
For example, we may want logic associated with a given DOM node without having to create an entire component specifically for that purpose. This exact problem is what a Directive aims to solve.
What Is a Directive
Our "Introduction to Components" chapter mentioned how a component is a collection of structures, styling, and logic that's associated with one or more HTML nodes.
Conversely, a directive is a collection of JavaScript logic that you can apply to a single DOM element.
While this comparison between a directive and a component seems stark, think about it: Components have a collection of JavaScript logic that's applied to a single "virtual" element.
As a result, some frameworks, like Angular, take this comparison literally and use directives under the hood to create components.
Here's what a basic directive looks like in each of the three frameworks:
- React
- Angular
- Vue
React as a framework doesn't quite have the concept of directives built in.
Luckily, this doesn't mean that we, as React developers, need to be left behind. Because a React component is effectively just a JavaScript function, we can use the base concept of a directive to create shared logic for DOM nodes.
Remember from our "Element Reference" chapter that you can use a function associated with an element's ref
property. We'll use this concept alongside the idea of a custom hook to create an API to add logic to an HTML element:
const useLogElement = () => { const ref = (el) => console.log(el); return { ref };};const App = () => { const { ref } = useLogElement(); return <p ref={ref}>Hello, world</p>;};
We'll continue to cover alternative APIs in React that can do much of the same as directives in other frameworks. In the meantime, it might be beneficial to broaden your horizons and take a glance at what a "true" directive looks like in other frameworks.
You set up a directive in Angular very similarly to how you might construct a component: using the @Directive
decorator.
import { Component, ElementRef, Directive } from "@angular/core";@Directive({ selector: "[sayHi]", standalone: true,})class LogElementDirective { constructor() { console.log("Hello, world!"); }}@Component({ selector: "app-root", standalone: true, imports: [LogElementDirective], template: ` <p sayHi>Hello, world</p> `,})class AppComponent {}
Here, we've told Angular to listen for any sayHi
attributes (using a CSS selector) and run a console.log
any time an element with said attribute is rendered.
This isn't particularly useful, but demonstrates the most minimal version of what a directive looks like.
Accessing a Directive's Underlying Element
It's frequently more helpful to get a reference to the element that the attribute is present on. To do this, we'll use Angular's dependency injection to ask Angular for an ElementRef
that's present within the framework's internals when you create a directive instance.
@Directive({ selector: "[logElement]", standalone: true,})class LogElementDirective { // el.nativeElement is a reference to the HTMLParagraphElement el = inject(ElementRef<any>);}
But oh no! Our directive no longer uses the constructor
function, which means that our console.log
no longer runs. This is because you can no longer use a constructor function when using the inject keyword.
To fix this, we can extract our inject
into a function that we can call from within our directive's class body:
function findAndLogTheElement() { const el = inject(ElementRef<any>); // HTMLParagraphElement console.log(el.nativeElement); return el;}@Directive({ selector: "[sayHi]", standalone: true,})class LogElementDirective { el = findAndLogTheElement();}
Setting up a directive in Vue sees you create an object within our setup
script
.
Inside this object, we'll add a key for created
and assign it a function to let Vue know to run said function when the directive is instantiated.
<!-- App.vue --><script setup>const vSayHi = { created: () => console.log("Hello, world!"),};</script><template> <p v-say-hi>Hello, world</p></template>
Directives in Vue must start with v-
prefix (which is why our object starts with v
) and are dash-cased
when presented inside a template
. This means that our vSayHi
object directive is turned into v-say-hi
when used in the template.
Accessing a Directive's Underlying Element
Instead of running a simple console.log
on a string, let's use the first argument passed to our directive's created
function
to access the underlying HTML element:
<!-- App.vue --><script setup>const vSayHi = { created: (el) => console.log(el),};</script><template> <p v-say-hi>Hello, world</p></template>
Once our apps load up, you should see a console.log
execute that prints out the HTMLParagraphElement reference.
You'll notice that these directives' logics are applied to elements through some means of an attribute-like selector, similar to how a component has a named tag associated with it.
Now that we've seen what a directive looks like, let's apply it to some real-world examples.
Basic Directives
Now that we have a reference to the underlying DOM node, we can use that to do various things with the element.
For example, let's say that we wanted to change the color of a button using nothing more than an HTML attribute — we can do that now using the HTMLElement's style
property:
- React
- Angular
- Vue
const useStyleBackground = () => { const ref = (el) => { el.style.background = "red"; }; return { ref };};const App = () => { const { ref } = useStyleBackground(); return <button ref={ref}>Hello, world</button>;};
function injectElAndStyle() { const el = inject(ElementRef<any>); el.nativeElement.style.background = "red"; return el;}@Directive({ selector: "[styleBackground]", standalone: true,})class StyleBackgroundDirective { el = injectElAndStyle();}@Component({ selector: "app-root", standalone: true, imports: [StyleBackgroundDirective], template: ` <button styleBackground>Hello, world</button> `,})class AppComponent {}
When using the created
method inside a directive, we can gain access to the underlying DOM node the directive is applied to using the function's arguments.
The first argument that's passed to created
is a DOM node reference that we can change the style
property of to style our button
.
<!-- App.vue --><script setup>const vStyleBackground = { created: (el) => { el.style.background = "red"; },};</script><template> <button v-style-background>Hello, world</button></template>
While this is a good demonstration of how you can use an element reference within a directive, styling an element is generally suggested to be done within a CSS file itself, unless you have good reason otherwise.
This is because styling an element through JavaScript can cause issues with server-side rendering, and can also cause layout thrashing if done incorrectly.
Side Effect Handlers in Directives
Previously, in the book, we've explored adding a focus
event when an element is rendered. However, in this chapter, we explicitly had to call a focus
method. What if we could have our button
focus itself immediately when it's rendered onto the page?
Luckily, with directives, we can!
See, while a component has a series of side effects associated with it: being rendered, updated, cleaned up, and beyond — so too does an HTML element that's bound to a directive!
Because of this, we can hook into the ability to use side effects within directives so that it focuses when an element is rendered.
- React
- Angular
- Vue
As we already know, we can use built-in React hooks in our custom hooks, which means that we can use useEffect
just like we could inside any other component.
const useFocusElement = () => { const [el, setEl] = useState(); useEffect(() => { if (!el) return; el.focus(); }, [el]); const ref = (localEl) => { setEl(localEl); }; return { ref };};const App = () => { const { ref } = useFocusElement(); return <button ref={ref}>Hello, world</button>;};
Truthfully, this is a bad example for
useEffect
. Instead, I would simply runlocalEl.focus()
inside of theref
function.
Angular uses the same implements
implementation for classes to use lifecycle methods in directives as it does components.
@Directive({ selector: "[focusElement]", standalone: true,})class StyleBackgroundDirective implements OnInit { el = inject(ElementRef<any>); ngOnInit() { this.el.nativeElement.focus(); }}@Component({ selector: "app-root", standalone: true, imports: [StyleBackgroundDirective], template: ` <button focusElement>Hello, world</button> `,})class AppComponent {}
Just as you can use the created
property on a directive object, you can change this property's name to match any of Vue's component lifecycle method names.
<!-- App.vue --><script setup>const vFocusElement = { mounted: (el) => { el.focus(); },};</script><template> <button v-focus-element>Hello, world</button></template>
For example, if we wanted to add a cleanup to this directive, we could change mounted
to unmounted
instead.
Passing Data to Directives
Let's look back at the directive we wrote to add colors to our button. It worked, but that red we were applying to the button
element was somewhat harsh, wasn't it?
We could just set the color to a nicer shade of red — say, #FFAEAE
— but then what if we wanted to re-use that code elsewhere to set a different button to blue?
To solve this issue regarding per-instance customization of a directive, let's add the ability to pass in data to a directive.
- React
- Angular
- Vue
Because a React Hook is a function at heart, we're able to pass values as we would to any other function:
const useStyleBackground = (color) => { const ref = (el) => { el.style.background = color; }; return { ref };};const App = () => { const { ref } = useStyleBackground("#FFAEAE"); return <button ref={ref}>Hello, world</button>;};
To pass a value to an Angular directive, we can use the @Input
directive, which is the same as a component.
However, one way that a directive's inputs differ from a component's is that you need to prepend the selector
value as the Input
variable name, like so:
@Directive({ selector: "[styleBackground]", standalone: true,})class StyleBackgroundDirective implements OnInit { @Input() styleBackground!: string; el = inject(ElementRef<any>); ngOnInit() { this.el.nativeElement.style.background = this.styleBackground; }}@Component({ selector: "app-root", standalone: true, imports: [StyleBackgroundDirective], template: ` <button styleBackground="#FFAEAE">Hello, world</button> `,})class AppComponent {}
Vue's directives are not simply functions — they are objects that contain functions and can access the value bound to the directive using arguments on each property.
While the first argument of each lifecycle's key is an element reference (el
), the second argument will always be the value assigned to the directive.
<!-- App.vue --><script setup>const vStyleBackground = { mounted: (el, binding) => { el.style.background = binding.value; },};</script><template> <button v-style-background="'#FFAEAE'">Hello, world</button></template>
You access the bindings' value through binding.value
, but can also access things like the previous value by using binding.oldValue
.
Passing JavaScript Values
Similar to how you can pass any valid JavaScript object to a component's inputs, you can do the same with a directive.
To demonstrate this, let's create a Color
class that includes the following properties:
class Color { constructor(r, g, b) { this.r = r; this.g = g; this.b = b; }}
Then, we can render out this color inside our background styling directive:
- React
- Angular
- Vue
class Color { constructor(r, g, b) { this.r = r; this.g = g; this.b = b; }}const colorInstance = new Color(255, 174, 174);const useStyleBackground = (color) => { const ref = (el) => { el.style.background = `rgb(${color.r}, ${color.g}, ${color.b})`; }; return { ref };};const App = () => { const { ref } = useStyleBackground(colorInstance); return <button ref={ref}>Hello, world</button>;};
class Color { r: number; g: number; b: number; constructor(r: number, g: number, b: number) { this.r = r; this.g = g; this.b = b; }}@Directive({ selector: "[styleBackground]", standalone: true,})class StyleBackgroundDirective implements OnInit { @Input() styleBackground!: Color; el = inject(ElementRef<any>); ngOnInit() { const color = this.styleBackground; this.el.nativeElement.style.background = `rgb(${color.r}, ${color.g}, ${color.b})`; }}@Component({ selector: "app-root", standalone: true, imports: [StyleBackgroundDirective], template: ` <button [styleBackground]="color">Hello, world</button> `,})class AppComponent { color = new Color(255, 174, 174);}
<!-- App.vue --><script setup>class Color { constructor(r, g, b) { this.r = r; this.g = g; this.b = b; }}const colorInstance = new Color(255, 174, 174);const vStyleBackground = { mounted: (el, binding) => { const color = binding.value; el.style.background = `rgb(${color.r}, ${color.g}, ${color.b})`; },};</script><template> <button v-style-background="colorInstance">Hello, world</button></template>
Now, we can customize the color using incremental updates to the RGB values of a color we're passing.
Passing Multiple Values
While a class instance of Color
may be useful in production apps, for smaller projects, it might be nicer to manually pass the r
, g
, and b
values directly to a directive without needing a class.
Just as we can pass multiple values to a component, we can do the same within a directive. Let's see how it's done for each of the three frameworks:
- React
- Angular
- Vue
Once again, the fact that a custom hook is still just a normal function provides us the ability to pass multiple arguments as if they are any other function.
const useStyleBackground = (r, g, b) => { const ref = (el) => { el.style.background = `rgb(${r}, ${g}, ${b})`; }; return { ref };};const App = () => { const { ref } = useStyleBackground(255, 174, 174); return <button ref={ref}>Hello, world</button>;};
I have to come clean about something: when I said, "A directive's input must be named the same as the attribute's selector," I was lying to keep things simple to explain.
In reality, you can name an input anything you'd like, but then you need to have an empty attribute with the same name as the selector.
@Directive({ selector: "[styleBackground]", standalone: true,})class StyleBackgroundDirective implements OnInit { @Input() r!: number; @Input() g!: number; @Input() b!: number; el = inject(ElementRef<any>); ngOnInit() { this.el.nativeElement.style.background = `rgb(${this.r}, ${this.g}, ${this.b})`; }}@Component({ selector: "app-root", standalone: true, imports: [StyleBackgroundDirective], template: ` <button styleBackground [r]="255" [g]="174" [b]="174">Hello, world</button> `,})class AppComponent {}
If you forget to include the attribute with the same selector (in this case,
styleBackground
), you'll get the following error:Can't bind to 'r' since it isn't a known property of 'button'.
Vue's directives do not directly support multiple arguments, as there's only one syntax to bind a value into a directive.
However, you can get around this limitation by passing an argument to the directive instead, like so:
<!-- App.vue --><script setup>const vStyleBackground = { mounted: (el, binding) => { const color = binding.value; el.style.background = `rgb(${color.r}, ${color.g}, ${color.b})`; },};</script><template> <button v-style-background="{ r: 255, g: 174, b: 174 }">Hello, world</button></template>
Conditionally Rendered UI via Directives
The examples we've used to build out basic directives have previously all mutated elements that don't change their visibility; these elements are always rendered on screen and don't change that behavior programmatically.
But what if we wanted a directive that helped us dynamically render an element like we do with our conditional rendering but using only an attribute to trigger the render?
Luckily, we can do that!
Let's build out a basic "feature flags" implementation, where we can decide if we want a part of the UI rendered based on specific values.
The basic idea of a feature flag is that you have multiple different UIs that you'd like to display to different users to test their effectiveness.
For example, say you want to test two different buttons and see which button gets your users to click on more items to purchase:
<button>Add to cart</button>
<button>Purchase this item</button>
You'd start a "feature flag" that separates your audience into two groups, show each group their respective button terminology, and measure their outcome on user's purchasing behaviors. You'd then take these measured results and use them to change the roadmap and functionality of your app.
While the separation of your users into "groups" (or "buckets") is typically done on the backend, let's just use a simple object for this demo.
const flags = { addToCartButton: true, purchaseThisItemButton: false,};
In this instance, we might render something like:
<button id="addToCart">Add to Cart</button>
Let's build a basic version of this in each of our frameworks.
- React
- Angular
- Vue
React has a unique ability that the other frameworks do not. Using JSX, you're able to assign a bit of HTML template into a variable... But that doesn't mean that you have to use that variable.
The idea in a feature flag is that you conditionally render UI components.
See where I'm going with this?
Let's store a bit of UI into a JSX variable and pass it to a custom React Hook that either returns the JSX or null
to render nothing, based on the flags
named boolean.
const flags = { addToCartButton: true, purchaseThisItemButton: false,};const useFeatureFlag = ({ flag, enabledComponent, disabledComponent = null,}) => { if (flags[flag]) { return { comp: enabledComponent }; } return { comp: disabledComponent, };};function App() { const { comp: addToCartComp } = useFeatureFlag({ flag: "addToCartButton", enabledComponent: <button>Add to cart</button>, }); const { comp: purchaseComp } = useFeatureFlag({ flag: "purchaseThisItemButton", enabledComponent: <button>Purchase this item</button>, }); return ( <div> {addToCartComp} {purchaseComp} </div> );}
Before we get into how to implement this functionality in Angular, I first need to circle back to how Angular uses ng-template
to define a group of HTML elements that can then be rendered after the fact.
While we previously have used ng-template
as a shorthand for "Don't render this until later," the tag is capable of so much more.
For starters, did you know that you can pass data to an ng-template
?
Passing Data to ng-template
Using ngTemplateOutletContext
To pass data to an ng-template
, you need to provide a "context" object for what should be passed.
For example, let's say that we want to pass a "name" to a template. We can provide an object that looks like:
{ name: "Corbin";}
And then render this data inside a template using:
<ng-template let-name="name"> <p>{{name}}</p></ng-template>
Here, we're saying that we want to bind the context key name
to a name
template variable. This template variable is then accessible to any HTML nodes under
the ng-template
.
However, because ng-template
doesn't render anything on its own, we'll need to supply a parent to render the ng-template
's contents. We do this using the ngTemplateOutlet
directive:
import { NgTemplateOutlet } from "@angular/common";@Component({ selector: "app-root", standalone: true, imports: [NgTemplateOutlet], template: ` <ng-template #templ let-name="name"> <p>{{ name }}</p> </ng-template> <div [ngTemplateOutlet]="templ" [ngTemplateOutletContext]="{ name: 'Corbin' }" ></div> `,})class AppComponent {}
We can even choose to use an ng-container
instead of a div
to avoid having a div
in our rendered output:
<ng-template #templ let-name="name"> <p>{{name}}</p></ng-template><ng-container [ngTemplateOutlet]="templ" [ngTemplateOutletContext]="{name: 'Corbin'}"></ng-container>
Default Keys in Template Context
Previously, we used a syntax like:
<ng-template let-name="name"> <p>{{name}}</p></ng-template>
To bind the name
variable to the name
context key. However, for contexts with a single value, this is a bit duplicative.
To solve this, we can pass a "default" key called $implicit
and bind it like so:
<ng-template let-name> <p>{{name}}</p></ng-template>
@Component({ selector: "app-root", standalone: true, imports: [NgTemplateOutlet], template: ` <ng-template #templ let-name>{{ name }}</ng-template> <div [ngTemplateOutlet]="templ" [ngTemplateOutletContext]="{ $implicit: 'Corbin' }" ></div> `,})class AppComponent {}
Seeing a Template Render a Comment
While we've been using inject
in directives to gain access to the directive's underlying HTML element, what happens if we bind a directive to an ng-template
?
@Directive({ selector: "[beOnTemplate]", standalone: true,})class TemplateDirective { constructor() { alert("I am alive!"); }}@Component({ selector: "app-root", standalone: true, imports: [TemplateDirective], template: ` <ng-template beOnTemplate><p>Hello, world</p></ng-template> `,})class AppComponent {}
Surprisingly, this alert
s the "I am alive!"
message despite nothing being shown on the screen!
Why is this?
Well, there's a hint if we try to access the underlying HTML element using ElementRef
:
@Directive({ selector: "[beOnTemplate]", standalone: true,})class TemplateDirective implements OnInit { el = inject(ElementRef<any>); ngOnInit() { // This will log a "Comment" console.log(this.el.nativeElement); }}@Component({ selector: "app-root", standalone: true, imports: [TemplateDirective], template: ` <ng-template beOnTemplate><p>Hello, world</p></ng-template> `,})class AppComponent {}
In this example, we've logged a Comment node. Interestingly, if we look at our rendered HTML, we'll see an HTML comment where our ng-template
was:
<!--container-->
While this is an implementation detail of Angular, it shows that Angular "renders" the ng-template
, which can trigger side effects like Angular's onInit
lifecycle methods. This is helpful when using a directive!
Access a Template from a Directive
Now that we know we can attach a template from a directive, let's go one step further and render the respective template.
Here, we'll use dependency injection to get access to an ng-template
's TemplateRef
:
function injectTemplateAndLog() { const template = inject(TemplateRef); console.log(template); return template;}@Directive({ selector: "[item]", standalone: true,})class ItemDirective { _template = injectTemplateAndLog();}@Component({ selector: "app-root", standalone: true, imports: [ItemDirective], template: ` <div> <ng-template item> <p>Hello, world!</p> </ng-template> </div> `,})class AppComponent {}
Because we're expecting Angular to pass an
ng-template
reference toItemDirective
, if we use theitem
attribute on anything other than a template, we'll end up with the following error:Error: NG0201: No provider for TemplateRef found. Find more at https://angular.dev/errors/NG0201
Doing this, we'll see that we get the TemplateRef
as expected in our console:
TemplateRef {_declarationLView: Array[34], _declarationTContainer: {…}, elementRef: {…}}
To render this TemplateRef
, we'll use a ViewContainerRef
.
Explaining Angular's Dom Structure
A ViewContainerRef
is a reference to the nearest ViewContainer
Huh?
Okay, okay, let's take a step back.
While Angular doesn't use a virtual DOM (VDOM) like React and Vue do, it does keep track of what is and isn't rendered.
To do this, Angular uses a compiler to create intelligent "template functions" when a component has a template
(or templateUrl
) field associated with it.
This means that:
import { Component } from "@angular/core";@Component({ selector: "app-cmp", template: "<span>Your name is {{name}}</span>",})class AppCmp { name = "Alex";}
Might compile to something like:
import { Component } from "@angular/core";import * as i0 from "@angular/core";class AppCmp { constructor() { this.name = "Alex"; }}AppCmp.ɵfac = function AppCmp_Factory(t) { return new (t || AppCmp)();};AppCmp.ɵcmp = i0.ɵɵdefineComponent({ type: AppCmp, selectors: [["app-cmp"]], decls: 2, vars: 1, template: function AppCmp_Template(rf, ctx) { if (rf & 1) { i0.ɵɵelementStart(0, "span"); i0.ɵɵtext(1); i0.ɵɵelementEnd(); } if (rf & 2) { i0.ɵɵadvance(1); i0.ɵɵtextInterpolate1("Your name is ", ctx.name, ""); } }, encapsulation: 2,});(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata( AppCmp, [ { type: Component, args: [ { selector: "app-cmp", template: "<span>Your name is {{name}}</span>", }, ], }, ], null, null, );})();
This code sample is taken from the "How the Angular Compiler Works" article written by the Angular team.
I explain how this code gets ran in detail in my "Angular Internals: How Reactivity Works with Zone.js" article.
When this compiler runs, it also creates a relationship between each component and their template. For each item in a template, there's either an EmbeddedView
for HTML elements or a Host View
for other components.
This means that this code:
@Component({ selector: "list-comp", standalone: true, template: ` <ul> <li>Say hi</li> <li>It's polite</li> </ul> `,})class ListComp {}@Component({ selector: "app-root", standalone: true, imports: [ListComp], template: ` <div> <p>Hello, world!</p> </div> <div> <p>Hello, humans!</p> </div> <list-comp /> `,})class AppComponent {}
Might be seen by Angular as such:
Using ViewContainer to Render a Template
This isn't just theoretically helpful to learn, though; we're able to tell Angular that we want to gain access to the underlying ViewContainer
via a ViewContainerRef
.
Similarly, as a template is handled by an EmbeddedView
in Angular's compiler, we can programmatically create an Embedded View using ViewContainerRef.createEmbeddedView
:
function injectAndRenderTemplate() { const templToRender = inject(TemplateRef<any>); const parentViewRef = inject(ViewContainerRef); parentViewRef.createEmbeddedView(templToRender); return templToRender;}@Directive({ selector: "[passBackground]", standalone: true,})class PassBackgroundDirective { template = injectAndRenderTemplate();}@Component({ selector: "app-root", standalone: true, imports: [PassBackgroundDirective], template: ` <div> <ng-template passBackground> <p>Hello, world!</p> </ng-template> </div> `,})class AppComponent {}
Now, we should be able to see the p
tag rendering!
Pass Data to Rendered Templates inside Directives
Just as we could pass data to a template inside a component using ngTemplateOutletContext
, we can do the same using a second argument of createEmbeddedView
:
function injectAndRenderTemplate() { const templToRender = inject(TemplateRef<any>); const parentViewRef = inject(ViewContainerRef); parentViewRef.createEmbeddedView(templToRender, { backgroundColor: "grey", }); return templToRender;}@Directive({ selector: "[passBackground]", standalone: true,})class PassBackgroundDirective { template = injectAndRenderTemplate();}@Component({ selector: "app-root", standalone: true, imports: [PassBackgroundDirective], template: ` <div> <ng-template passBackground let-backgroundColor="backgroundColor"> <p [style]="{ backgroundColor }">Hello, world!</p> </ng-template> </div> `,})class AppComponent {}
Use Structural Directives to Make Work Easier
In our previous section, we used an ng-template
combined with a div
to render our app with the correct DOM structure.
However, did you know that adding *
next to a directive turns it into a "Structural Directive"?
Doing so tells the directive to wrap the element inside an ng-template
to use later.
This:
<div> <ng-template someDirective> <p>Hi</p> </ng-template></div>
Is functionally the same as this:
<div *someDirective> <p>Hi</p></div>
Knowing this, we can take our previous code and convert it to a structural directive:
@Component({ selector: "app-root", standalone: true, imports: [PassBackgroundDirective], template: ` <div *passBackground="let backgroundColor = backgroundColor"> <p [style]="{ backgroundColor }">Hello, world!</p> </div> `,})class AppComponent {}
Structural directives are immensely powerful! I wrote a 10k word long blog post all about them here.
Build the Feature Flag Behavior Using Structural Templates
Now that we have our foundation written out, we can finally build a simple featureFlag
directive that renders nothing if a flag
is false but renders the contents if a flag is true
:
const flags: Record<string, boolean> = { addToCartButton: true, purchaseThisItemButton: false,};@Directive({ selector: "[featureFlag]", standalone: true,})class FeatureFlagDirective implements OnChanges { @Input() featureFlag!: string; templToRender = inject(TemplateRef<any>); parentViewRef = inject(ViewContainerRef); embeddedView: EmbeddedViewRef<any> | null = null; ngOnChanges() { if (flags[this.featureFlag]) { this.embeddedView = this.parentViewRef.createEmbeddedView( this.templToRender, ); } else if (this.embeddedView) { this.embeddedView.destroy(); } }}@Component({ selector: "app-root", standalone: true, imports: [FeatureFlagDirective], template: ` <div> <button *featureFlag="'addToCartButton'">Add to cart</button> <button *featureFlag="'purchaseThisItemButton'"> Purchase this item </button> </div> `,})class AppComponent {}
Unlike React and Angular, Vue does not have a way of storing parts of a template inside a variable without rendering it on-screen.
While Vue does have the ability to use the
template
tag in some ways, it ultimately serves a different purpose than the one we're trying to implement here.
As a result, Vue is unable to implement a featureFlag
directive out-of-the-box without some major code overhaul.
Instead, it's suggested to use a component to conditionally render parts of the UI instead:
<!-- FeatureFlag.vue --><script setup>const flags = { addToCartButton: true, purchaseThisItemButton: false,};const props = defineProps(["name"]);</script><template> <slot v-if="flags[props.name]"></slot></template>
<!-- App.vue --><script setup>import FeatureFlag from "./FeatureFlag.vue";</script><template> <FeatureFlag name="addToCartButton"> <button>Add to cart</button> </FeatureFlag> <FeatureFlag name="purchaseThisItemButton"> <button>Purchase this item</button> </FeatureFlag></template>
In my opinion, this is not as clean as using a directive since you need to have two additional HTML tags, but that's just one of the limitations of Vue's directive.
That said, this method is reasonable extensible as you can even use this FeatureFlag
component to fetch data from the server using an Async Component, a concept built into Vue.
While you could theoretically use things like
el.innerHTML
to mutate the HTML of a DOM node inside a directive to display what you'd like to, there are a few major limitations:
- No live updating the values within said HTML
- Difficult to capture DOM events
- Slow performance
- Brittle and easy to break
Challenge
This code was functional and led to a nice user experience, but the tooltip wasn't broken out into its own components, making it challenging to share the code elsewhere.
Let's refactor that code so that we can add a tooltip
directive so that adding a tooltip is as easy as adding an attribute! To make this challenge more focused on what we've learned in this chapter, let's simplify the design of our tooltip to something like the following:
To build this, we'll need to:
- Add a tooltip directive
- Allow for an input to the directive that's the contents
- Bind the tooltip to a button element
We can use the following CSS for the tooltip itself:
.tooltip { position: absolute; background-color: #333; color: #fff; padding: 8px; border-radius: 4px; z-index: 1000;}
Let's get started.
- React
- Angular
- Vue
To avoid having to add manual event listeners to our button, let's pass in a button and the contents of the tooltip using properties on a useTooltip
custom hook:
const useTooltip = ({ tooltipContents, innerContents }) => { const [isVisible, setIsVisible] = useState(false); const targetRef = useRef(); const tooltipRef = useRef(); const showTooltip = () => { setIsVisible(true); }; const hideTooltip = () => { setIsVisible(false); }; useEffect(() => { if (!isVisible || !tooltipRef.current || !targetRef.current) return; const targetRect = targetRef.current.getBoundingClientRect(); tooltipRef.current.style.left = `${targetRect.left}px`; tooltipRef.current.style.top = `${targetRect.bottom}px`; }, [isVisible]); return ( <div> <div ref={targetRef} onMouseEnter={showTooltip} onMouseLeave={hideTooltip} > {innerContents} </div> {isVisible && createPortal( <div ref={tooltipRef} className="tooltip"> {tooltipContents} </div>, document.body, )} </div> );};const App = () => { const tooltip = useTooltip({ innerContents: <button>Hover me</button>, tooltipContents: "This is a tooltip", }); return ( <div> {tooltip} <style children={` .tooltip { position: absolute; background-color: #333; color: #fff; padding: 8px; border-radius: 4px; z-index: 1000; } `} /> </div> );};
Final code output
To get this working as expected in Angular, we'll combine everything we learned about structural directives alongside our typical directive knowledge.
Our API will eventually look like this:
<button #tooltipBase>Hover me</button><div *tooltip="tooltipBase">This is a tooltip</div>
However, to get this to work, we need to use Angular CDK's DOMPortal instead of our previously known TemplatePortal
, so that we can teleport the div
itself to body
and add properties to the div
when we do so.
We'll build out a custom PortalService that uses a DomPortalOutlet
to enable this:
@Injectable({ providedIn: "root",})class PortalService { outlet = new DomPortalOutlet( document.querySelector("body")!, undefined, undefined, undefined, document, );}
This code sample has a few peculiar
undefined
s here. This is because Angular will otherwise not work as intended without them.The order of the properties in
DomPortalOutlet
will likely change in the future allowingdocument
to be passed first, but has not yet at the time of writing.
Then, we can bind to the DomPortalOutput
by creating a DOMPortal
like so:
const viewRef = this.viewContainerRef.createEmbeddedView(this.templToRender);// We need to access the `div` itself to attach to a DomPortal; this is how you do that.this.el = viewRef.rootNodes[0] as HTMLElement;// Now that we have the element reference, we can add a class and style propertiesthis.el.classList.add("tooltip");this.el.style.left = `${left}px`;this.el.style.top = `${bottom}px`;setTimeout(() => { this.portalService.outlet.attach(new DomPortal(this.el));});
Let's put it all together like so:
@Directive({ selector: "[tooltip]", standalone: true,})class TooltipDirective implements AfterViewInit, OnDestroy { @Input("tooltip") tooltipBase!: HTMLElement; viewContainerRef = inject(ViewContainerRef); templToRender = inject(TemplateRef<any>); portalService = inject(PortalService); ngAfterViewInit() { this.tooltipBase.addEventListener("mouseenter", () => { this.showTooltip(); }); this.tooltipBase.addEventListener("mouseleave", () => { this.hideTooltip(); }); } el: HTMLElement | null = null; showTooltip = () => { const { left, bottom } = this.tooltipBase.getBoundingClientRect(); const viewRef = this.viewContainerRef.createEmbeddedView( this.templToRender, ); this.el = viewRef.rootNodes[0] as HTMLElement; this.el.classList.add("tooltip"); this.el.style.left = `${left}px`; this.el.style.top = `${bottom}px`; setTimeout(() => { this.portalService.outlet.attach(new DomPortal(this.el)); }); }; hideTooltip = () => { // Detaching the portal does not remove the element from the DOM when using DomPortal rather than TemplatePortal this.portalService.outlet.detach(); this.el?.remove(); }; ngOnDestroy() { this.hideTooltip(); }}@Component({ selector: "app-root", standalone: true, imports: [TooltipDirective], template: ` <div> <button #tooltipBase>Hover me</button> <div *tooltip="tooltipBase">This is a tooltip</div> </div> `, encapsulation: ViewEncapsulation.None, styles: [ ` .tooltip { position: absolute; background-color: #333; color: #fff; padding: 8px; border-radius: 4px; z-index: 1000; } `, ],})class AppComponent {}
And suddenly, our code works as we would expect!
Final code output
The code from the other frameworks cannot be ported to Vue due to limitations with Vue's directives. This is because Vue does not have a way to store a template in a variable without rendering it on-screen.
That said, it would be pretty frustrating to not have anything to show for the end of this chapter. Keeping in mind the limitation of Vue's directives, let's build a directive that can be used to show a tooltip on hover:
<!-- App.vue --><script setup>import { vTooltip } from "./vTooltip.js";const obj = { current: null };</script><template> <div> <button :ref="(el) => (obj.current = el)">Hover me</button> <!-- Anything passed to `v-tooltip` is not reactive, so we need to use an object with a mutable `current` property --> <div v-tooltip="obj">This is a tooltip</div> </div></template><style>.tooltip { position: absolute; background-color: #333; color: #fff; padding: 8px; border-radius: 4px; z-index: 1000;}</style>
// vTooltip.jsexport const vTooltip = { beforeMount: (el) => { el.style.display = "none"; }, mounted: (el, binding) => { const baseEl = binding.value.current; el.classList.add("tooltip"); baseEl.addEventListener("mouseenter", () => { const baseRect = baseEl.getBoundingClientRect(); el.style.left = `${baseRect.left}px`; el.style.top = `${baseRect.bottom}px`; el.style.display = "block"; }); baseEl.addEventListener("mouseleave", () => { el.style.display = "none"; }); },};
This doesn't work quite the same as the component version of
<Tooltip>
Namely, it doesn't teleport the tooltip to the body, which might cause issues If you have a stacking context on the parent element.It also doesn't remove the tooltip from the DOM when it's not visible, but rather just hides it using
display: none
. This may cause performance issues if you have a lot of tooltips on the page.