While React, Angular, and Vue all provide simple built-in APIs to access events, inputs, and other bindings to underlying HTML elements; sometimes it's just not enough.
For example, let's say that we want to build a right-click menu so that a user can see a relevant list of actions associated with the file the user is currently hovering over:
We're able to build some of this functionality with the APIs we've covered thus far, namely, we can:
Using our framework's event binding to listen to the browser's contextmenu event which lets us know when the user has right-clicked
Conditionally rendering the context menu's elements until relevant
Binding the style attribute to position the popup's x and y value
React
Angular
Vue
/** * This code sample is inaccessible and generally not * production-grade. It's missing: * - Focus on menu open * - Closing upon external click * * Read on to learn how to add these */function App() { const [mouseBounds, setMouseBounds] = useState({ x: 0, y: 0, }); const [isOpen, setIsOpen] = useState(false); function onContextMenu(e) { e.preventDefault(); setIsOpen(true); setMouseBounds({ // Mouse position on click x: e.clientX, y: e.clientY, }); } return ( <> <div style={{ marginTop: "5rem", marginLeft: "5rem" }}> <div onContextMenu={onContextMenu}>Right click on me!</div> </div> {isOpen && ( <div style={{ position: "fixed", top: `${mouseBounds.y}px`, left: `${mouseBounds.x}px`, background: "white", border: "1px solid black", borderRadius: 16, padding: "1rem", }} > <button onClick={() => setIsOpen(false)}>X</button> This is a context menu </div> )} </> );}
<!-- App.vue --><!-- This code sample is inaccessible and generally not --><!-- production-grade. It's missing: --><!-- - Focus on menu open --><!-- - Closing upon external click --><!-- --><!-- Read on to learn how to add these --><script setup>import { ref } from "vue";const isOpen = ref(false);const mouseBounds = ref({ x: 0, y: 0,});const close = () => { isOpen.value = false;};const open = (e) => { e.preventDefault(); isOpen.value = true; mouseBounds.value = { // Mouse position on click x: e.clientX, y: e.clientY, };};</script><template> <div style="margin-top: 5rem; margin-left: 5rem"> <div @contextmenu="open($event)">Right click on me!</div> </div> <div v-if="isOpen" :style="` position: fixed; top: ${mouseBounds.y}px; left: ${mouseBounds.x}px; background: white; border: 1px solid black; border-radius: 16px; padding: 1rem; `" > <button @click="close()">X</button> This is a context menu </div></template>
This works relatively well until we think about two features that are missing:
Listening for any click outside the popup's contents
Focusing on the popup's contents when the user right-clicks, keyboard shortcuts apply to the popup immediately
While these features are possible without any newly introduced APIs, they'd both require you to use browser APIs such as document.querySelector to eject away from the framework's limitations.
In those rare events, you want to eject away from the framework controlling your access to HTML nodes; each framework enables you to access the underlying DOM nodes without using browser APIs specifically. This allows our code to still retain full control over the underlying elements while remaining within the reactivity systems these frameworks provide.
In this chapter, we'll learn:
How to reference the underlying DOM element
How to reference an array of elements
Adding focus and external click listening to the context menu
A code challenge to re-enforce knowledge
Basic Element References
React
Angular
Vue
In React, there's no simpler demonstration of an element reference than passing a function to an element's ref property.
const RenderParagraph = () => { // el is HTMLElement return <p ref={(el) => console.log({ el: el })}>Check your console</p>;};
import { createRoot } from "react-dom/client";const RenderParagraph = () => { // el is HTMLElement return <p ref={(el) => console.log({ el: el })}>Check your console</p>;};createRoot(document.getElementById("root")).render(<RenderParagraph />);
In this example, once the paragraph tags renders, it will console.log the underlying HTML DOM node.
You may be wondering where the ref property has come from, since it's not a known HTMLElement property. This is because ref is a reserved property by React for this special case.
Knowing that we can pass a function to gain access to the HTML DOM node, we can pass a function that adds an event listener to the HTML element.
const RenderButton = () => { // el is HTMLElement const addClickListener = (el) => { el.addEventListener("click", () => { alert("User has clicked!"); }); }; return <button ref={addClickListener}>Click me!</button>;};
import { createRoot } from "react-dom/client";const RenderButton = () => { // el is HTMLElement const addClickListener = (el) => { el.addEventListener("click", () => { alert("User has clicked!"); }); }; return <button ref={addClickListener}>Click me!</button>;};createRoot(document.getElementById("root")).render(<RenderButton />);
This is just used as an example of what you can do with the underlying HTML element. While there are perfectly valid reasons for using ref to addEventListener (we'll touch on one such case later on), it's usually suggested to use onClick style event bindings instead.
useStaterefs
However, this is a problem because our addEventListener is never cleaned up! Remember, this is part of the API that useEffect provides.
As a result, let's store the value of el into a useState, then pass that value into a useEffect, which will then add the event listener:
const CountButton = () => { const [count, setCount] = useState(0); const [buttonEl, setButtonEl] = useState(); const storeButton = (el) => { setButtonEl(el); }; useEffect(() => { // Initial render will not have `buttonEl` defined, subsequent renders will if (!buttonEl) return; const clickFn = () => { /** * We need to use `v => v + 1` instead of `count + 1`, otherwise `count` will * be stale and not update further than `1`. More details in the next paragraph. */ setCount((v) => v + 1); }; buttonEl.addEventListener("click", clickFn); return () => { buttonEl.removeEventListener("click", clickFn); }; }, [buttonEl]); return ( <> <button ref={storeButton}>Add one</button> <p>Count is {count}</p> </> );};
import { createRoot } from "react-dom/client";import { useState, useEffect } from "react";const CountButton = () => { const [count, setCount] = useState(0); const [buttonEl, setButtonEl] = useState(); const storeButton = (el) => { setButtonEl(el); }; useEffect(() => { // Initial render will not have `buttonEl` defined, subsequent renders will if (!buttonEl) return; const clickFn = () => { /** * We need to use `v => v + 1` instead of `count + 1`, otherwise `count` will * be stale and not update further than `1`. More details in the next paragraph. */ setCount((v) => v + 1); }; buttonEl.addEventListener("click", clickFn); return () => { buttonEl.removeEventListener("click", clickFn); }; }, [buttonEl]); return ( <> <button ref={storeButton}>Add one</button> <p>Count is {count}</p> </> );};createRoot(document.getElementById("root")).render(<CountButton />);
Once again: You should be using onClick to bind a method, this is only to demonstrate how element refs work
You'll notice in this example that within our useEffect, we're using a function to update setCount. This is because otherwise, we will run into a "Stale Closure", which means that our count value will never update past 1.
const App = () => { const divRef = useRef(); // Ta-da! No need to pass a function when using `useRef` and `ref` together return <div ref={divRef} />;};
Let's look at how using an element reference using useRef could cause havoc when binding an event via addEventListener. Here, we can see an example of what useRef might look like in our CountButton example:
const CountButton = () => { const [count, setCount] = useState(0); const buttonRef = useRef(); useEffect(() => { const clickFn = () => { setCount((v) => v + 1); }; buttonRef.current.addEventListener("click", clickFn); return () => { buttonRef.current.removeEventListener("click", clickFn); }; // Do not use a useRef inside of a useEffect like this, it will likely cause bugs }, [buttonRef.current]); return ( <> <button ref={buttonRef}>Add one</button> <p>Count is {count}</p> </> );};
import { createRoot } from "react-dom/client";import { useState, useRef, useEffect } from "react";const CountButton = () => { const [count, setCount] = useState(0); const buttonRef = useRef(); useEffect(() => { const clickFn = () => { setCount((v) => v + 1); }; buttonRef.current.addEventListener("click", clickFn); return () => { buttonRef.current.removeEventListener("click", clickFn); }; // Do not use a useRef inside of a useEffect like this, it will likely cause bugs }, [buttonRef.current]); return ( <> <button ref={buttonRef}>Add one</button> <p>Count is {count}</p> </> );};createRoot(document.getElementById("root")).render(<CountButton />);
This works as we would expect because buttonRef is defined before the first run of useEffect. However, let's add a short delay to the button's rendering. We can do this using a setTimeout and another useEffect:
// This code intentionally doesn't work to demonstrate how `useRef`// might not work with `useEffect` as you might thinkconst CountButton = () => { const [count, setCount] = useState(0); const buttonRef = useRef(); const [showButton, setShowButton] = useState(false); useEffect(() => { const interval = setInterval(() => { setShowButton(true); }, 1000); return () => clearInterval(interval); }, []); useEffect(() => { const clickFn = () => { setCount((v) => v + 1); }; if (!buttonRef.current) return; buttonRef.current.addEventListener("click", clickFn); return () => { buttonRef.current.removeEventListener("click", clickFn); }; }, [buttonRef.current]); return ( <> {showButton && <button ref={buttonRef}>Add one</button>} <p>Count is {count}</p> </> );};
import { createRoot } from "react-dom/client";import { useState, useRef, useEffect } from "react";// This code intentionally doesn't work to demonstrate how `useRef`// might not work with `useEffect` as you might thinkconst CountButton = () => { const [count, setCount] = useState(0); const buttonRef = useRef(); const [showButton, setShowButton] = useState(false); useEffect(() => { const interval = setInterval(() => { setShowButton(true); }, 1000); return () => clearInterval(interval); }, []); useEffect(() => { const clickFn = () => { setCount((v) => v + 1); }; if (!buttonRef.current) return; buttonRef.current.addEventListener("click", clickFn); return () => { buttonRef.current.removeEventListener("click", clickFn); }; }, [buttonRef.current]); return ( <> {showButton && <button ref={buttonRef}>Add one</button>} <p>Count is {count}</p> </> );};createRoot(document.getElementById("root")).render(<CountButton />);
Now, if we wait the second it takes to render the <button>Add one</button> element and press the button, we'll see that our click event handler is never set properly.
This is because buttonRef.current is set to undefined in the first render, and the mutation of buttonRef when the <button> element is rendered does not trigger a re-render, which in turn does not re-run useEffect to add the event binding.
This is not to say that you shouldn't use useRef for element reference, just that you should be aware of its downfalls and alternatives.
We'll see some usage of the ref property with useRef in a bit.
Using viewChild, we can access an HTMLElement that's within an Angular component's template:
You may notice that our <p> tag has an attribute prefixed with a pound sign (#). This pound-sign prefixed attribute allows Angular to associate the element with a "template reference variable," which can then be referenced inside our viewChild to gain access to an element.
For example, the #pTag attribute assigns the template reference variable named "pTag" to the <p> element and allows viewChild to find that element based on the variable's name.
Now that we have access to the underlying <p> element let's print it out inside an effect:
ERROR RuntimeError: NG0951: Child query result is required but no value is available. Find more at https://angular.dev/errors/NG0951
Oh dear! This error is being thrown by the .required part of viewChild, telling us that pTag was not found by the time the value was read. Even if we remove .required, pTag is still undefined when effect is first ran.
Well, Angular doesn't yet know that <p> is going to exist due to the @if block. It might or it might not, depending on the input.
As a result, the viewChild is not accessible until after the component's first render; when Angular has had time to figure out if it should display the element and renders it to the DOM based off of the respective input.
To solve for this, we need to move away from reading the viewChild using effect and instead read it using afterRenderEffect:
Vue's ability to store reactive data using ref enables a super simplistic API to access DOM nodes and create a ref with the same variable name as a ref property of an element's ref attribute value.
<!-- App.vue --><script setup>import { ref, onMounted } from "vue";// Assign the refconst el = ref();// Use the refonMounted(() => { console.log(el);});</script><template> <p ref="el">Check your console</p></template>
<!-- App.vue --><script setup>import { ref, onMounted } from "vue";// Assign the refconst el = ref();// Use the refonMounted(() => { console.log(el.value);});</script><template> <p ref="el">Check your console</p></template>
Here, el.value points to an HTMLElement of the p tag within template.
Vue also allows you to pass a function to ref in order to run a function when the ref is being set, like so:
Let's say that we're building an email application and want to provide the user a button that scrolls them to the top of their messages quickly.
One way of building out this button is to store each underlying message's DOM element in the array into an element reference then use the top and bottom elements' scrollIntoView method to bring them onto the page visually.
Let's see how that's done with each framework.
React
Angular
Vue
React's ability to persist data within a useRef allows us to create an index-based array to store our elements into.
Using this array, we can then access the 0th and last index (using messages.length - 1) to indicate the first and last element, respectively.
const messages = [ "The new slides for the design keynote look wonderful!", "Some great new colours are planned to debut with Material Next!", "Hey everyone! Please take a look at the resources I’ve attached.", "So on Friday we were thinking about going through that park you’ve recommended.", "We will discuss our upcoming Pixel 6 strategy in the following week.", "On Thursday we drew some great new ideas for our talk.", "So the design teams got together and decided everything should be made of sand.",];function App() { const messagesRef = useRef([]); const scrollToTop = () => { messagesRef.current[0].scrollIntoView(); }; const scrollToBottom = () => { messagesRef.current[messagesRef.current.length - 1].scrollIntoView(); }; return ( <div> <button onClick={scrollToTop}>Scroll to top</button> <ul style={{ height: "50px", overflow: "scroll" }}> {messages.map((message, i) => { return ( <li key={i} ref={(el) => (messagesRef.current[i] = el)}> {message} </li> ); })} </ul> <button onClick={scrollToBottom}>Scroll to bottom</button> </div> );}
import { createRoot } from "react-dom/client";import { useRef } from "react";const messages = [ "The new slides for the design keynote look wonderful!", "Some great new colours are planned to debut with Material Next!", "Hey everyone! Please take a look at the resources I’ve attached.", "So on Friday we were thinking about going through that park you’ve recommended.", "We will discuss our upcoming Pixel 6 strategy in the following week.", "On Thursday we drew some great new ideas for our talk.", "So the design teams got together and decided everything should be made of sand.",];function App() { const messagesRef = useRef([]); const scrollToTop = () => { messagesRef.current[0].scrollIntoView(); }; const scrollToBottom = () => { messagesRef.current[messagesRef.current.length - 1].scrollIntoView(); }; return ( <div> <button onClick={scrollToTop}>Scroll to top</button> <ul style={{ height: "50px", overflow: "scroll" }}> {messages.map((message, i) => { return ( <li key={message} ref={(el) => (messagesRef.current[i] = el)}> {message} </li> ); })} </ul> <button onClick={scrollToBottom}>Scroll to bottom</button> </div> );}createRoot(document.getElementById("root")).render(<App />);
Just as there is a viewChild to gain access to a single underlying HTML element, you can also use a viewChildren to access more than one or more template elements using similar APIs.
Using viewChildren, we can access in-template variables (prefixed with #) to scrollIntoView the first and last elements.
@Component({ selector: "app-root", changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <button (click)="scrollToTop()">Scroll to top</button> <ul style="height: 100px; overflow: scroll"> @for (message of messages; track message) { <!-- Create a new template variable called listItem --> <!-- for each item in the `messages` array --> <li #listItem> {{ message }} </li> } </ul> <button (click)="scrollToBottom()">Scroll to bottom</button> </div> `,})class AppComponent { // Reference the template variable `listItem` els = viewChildren("listItem", { read: ElementRef<HTMLElement> }); scrollToTop() { this.els()[0]!.nativeElement.scrollIntoView(); } scrollToBottom() { this.els()[this.els().length - 1]!.nativeElement.scrollIntoView(); } messages = [ "The new slides for the design keynote look wonderful!", "Some great new colours are planned to debut with Material Next!", "Hey everyone! Please take a look at the resources I’ve attached.", "So on Friday we were thinking about going through that park you’ve recommended.", "We will discuss our upcoming Pixel 6 strategy in the following week.", "On Thursday we drew some great new ideas for our talk.", "So the design teams got together and decided everything should be made of sand.", ];}
import { bootstrapApplication } from "@angular/platform-browser";import { Component, ElementRef, viewChildren, provideExperimentalZonelessChangeDetection, ChangeDetectionStrategy,} from "@angular/core";@Component({ selector: "app-root", changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <button (click)="scrollToTop()">Scroll to top</button> <ul style="height: 100px; overflow: scroll"> @for (message of messages; track message) { <li #listItem> {{ message }} </li> } </ul> <button (click)="scrollToBottom()">Scroll to bottom</button> </div> `,})class AppComponent { els = viewChildren("listItem", { read: ElementRef<HTMLElement> }); scrollToTop() { this.els()[0]!.nativeElement.scrollIntoView(); } scrollToBottom() { this.els()[this.els().length - 1]!.nativeElement.scrollIntoView(); } messages = [ "The new slides for the design keynote look wonderful!", "Some great new colours are planned to debut with Material Next!", "Hey everyone! Please take a look at the resources I’ve attached.", "So on Friday we were thinking about going through that park you’ve recommended.", "We will discuss our upcoming Pixel 6 strategy in the following week.", "On Thursday we drew some great new ideas for our talk.", "So the design teams got together and decided everything should be made of sand.", ];}bootstrapApplication(AppComponent, { providers: [provideExperimentalZonelessChangeDetection()],});
<!-- App.vue --><script setup>import { ref } from "vue";const items = ref([]);function scrollToTop() { items.value[0].scrollIntoView();}function scrollToBottom() { items.value[items.value.length - 1].scrollIntoView();}const messages = [ "The new slides for the design keynote look wonderful!", "Some great new colours are planned to debut with Material Next!", "Hey everyone! Please take a look at the resources I’ve attached.", "So on Friday we were thinking about going through that park you’ve recommended.", "We will discuss our upcoming Pixel 6 strategy in the following week.", "On Thursday we drew some great new ideas for our talk.", "So the design teams got together and decided everything should be made of sand.",];</script><template> <div> <button @click="scrollToTop()">Scroll to top</button> <ul style="height: 100px; overflow: scroll"> <li v-for="(message, i) of messages" :key="i" ref="items"> {{ message }} </li> </ul> <button @click="scrollToBottom()">Scroll to bottom</button> </div></template>
<!-- App.vue --><script setup>import { ref } from "vue";const items = ref([]);function scrollToTop() { items.value[0].scrollIntoView();}function scrollToBottom() { items.value[items.value.length - 1].scrollIntoView();}const messages = [ "The new slides for the design keynote look wonderful!", "Some great new colours are planned to debut with Material Next!", "Hey everyone! Please take a look at the resources I’ve attached.", "So on Friday we were thinking about going through that park you’ve recommended.", "We will discuss our upcoming Pixel 6 strategy in the following week.", "On Thursday we drew some great new ideas for our talk.", "So the design teams got together and decided everything should be made of sand.",];</script><template> <div> <button @click="scrollToTop()">Scroll to top</button> <ul style="height: 100px; overflow: scroll"> <li v-for="(message, i) of messages" :key="i" ref="items"> {{ message }} </li> </ul> <button @click="scrollToBottom()">Scroll to bottom</button> </div></template>
Real World Usage
Now that we know how to access an underlying HTML element in our given framework let's go back to our previous context menu example from the start of the chapter.
See, while our context menu was able to show properly, we were missing two distinct features:
Focusing the dropdown element when opened
Closing the context menu when the user clicks elsewhere
Let's add this functionality to our context menu component.
This code in vanilla JavaScript might look something like this:
<button id="clickInside"> If you click outside of this button, it will hide</button><script> const clickInsideButton = document.querySelector("#clickInside"); function listenForOutsideClicks(e) { // This check is saying "`true` if the clicked element is a child of the 'clickInside' button" const isClickInside = clickInsideButton.contains(e.target); if (isClickInside) return; // Hide the button using CSS. In frameworks, we'd use conditional rendering. clickInsideButton.style.display = "none"; } document.addEventListener("click", listenForOutsideClicks);</script>
Let's port this logic to React, Angular, and Vue:
React
Angular
Vue
Let's add a ref usage that stores our contextMenu inside of a useState.
Then, when we change the value of contextMenu, we can .focus the element and use the addEventListener code from above:
Let's adopt the above click listener and apply it within our onMounted lifecycle method.
We'll also use a callback ref to run a function every time the context menu is open. This function will then either do nothing or call .focus on the element depending on whether it's rendered or not.
Let's build out a fresh component from our understanding of element reference.
Specifically, let's build out tooltip functionality so that when the user hovers over a button for a second or longer, it displays a popup message to help the user understand how it's used.
To do this, we'll need to consider a few things:
How to track when the user has hovered over an element for a second or longer
How to remove the popup when the user has moved their mouse
Make sure the tooltip is positioned above the button
Make sure the tooltip is horizontally centered
Adding any necessary polish
Step 1: Track When the User Has Hovered an Element
To center a position: fixed element is a challenge and a half. While there's half a dozen ways we could go about this, we're going to opt for a solution that involves:
Creating a <div> with the same width as the button
Making this <div> a display: flex element with justify-content: center CSS applied to center all children
Allowing overflow inside the div using overflow: visible
Placing our tooltip's text inside the <div> with white-space: nowrap applied to avoid our text wrapping to meet the <div> width.
This works because the <div>'s position should mirror the button's and allow content to be centered around it, like so:
In the end, our styling should look something like this HTML markup:
<div style="padding: 10rem"> <!-- The PX values here may differ on your system --> <div style=" display: flex; overflow: visible; justify-content: center; width: 40.4667px; position: fixed; top: 138.8px; left: 168px; " > <div style="white-space: nowrap"> This will send an email to the recipients </div> </div> <button>Send</button></div>