Post contents
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'sx
andy
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> )} </> );}
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>;};
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 knownHTMLElement
property. This is becauseref
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>;};
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
toaddEventListener
(we'll touch on one such case later on), it's usually suggested to useonClick
style event bindings instead.
useState
ref
s
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> </> );};
Once again: You should be using
onClick
to bind a method, this is only to demonstrate how elementref
s 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
.
Why Aren't We Using useRef
?
If you think back to an earlier chapter in the book, "Side Effects", you may remember our usage of a hook called "useRef
". Sensibly, based on the name, it's very commonly used with an element's ref
property. In fact, it's so commonly used to store an element's reference that it even has a shorthand:
const App = () => { const divRef = useRef(); // Ta-da! No need to pass a function when using `useRef` and `ref` together return <div ref={divRef} />;};
Knowing this, why aren't we using useRef
in the previous button counter example? Well, the answer goes back to the "Side Effects" chapter once again. Back in the said chapter, we explained how useRef
doesn't trigger useEffect
s as one might otherwise expect.
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> </> );};
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> </> );};
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 withuseRef
in a bit.
How to Keep an Array of Element References
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 0
th 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> );}
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.
To add the first feature, we'll focus on the context menu using element.focus()
in order to make sure that keyboard users aren't lost when trying to use the feature.
To add the second feature, let's:
- Add a listener for any time the user clicks on a page
- Inside that click listener, get the event's
target
property- The event target is the element that the user is taking an action on - AKA the element the user is currently clicking on
- We then check if that
target
is inside the context menu or not using theelement.contains
method.
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:
function App() { const [mouseBounds, setMouseBounds] = useState({ x: 0, y: 0, }); const [isOpen, setIsOpen] = useState(false); function onContextMenu(e) { e.preventDefault(); setIsOpen(true); setMouseBounds({ x: e.clientX, y: e.clientY, }); } const [contextMenu, setContextMenu] = useState(); useEffect(() => { if (contextMenu) { contextMenu.focus(); } }, [contextMenu]); useEffect(() => { if (!contextMenu) return; const closeIfOutsideOfContext = (e) => { const isClickInside = contextMenu.contains(e.target); if (isClickInside) return; setIsOpen(false); }; document.addEventListener("click", closeIfOutsideOfContext); return () => document.removeEventListener("click", closeIfOutsideOfContext); }, [contextMenu]); return ( <> <div style={{ marginTop: "5rem", marginLeft: "5rem" }}> <div onContextMenu={onContextMenu}>Right click on me!</div> </div> {isOpen && ( <div ref={(el) => setContextMenu(el)} tabIndex={0} style={{ position: "fixed", top: mouseBounds.y, left: mouseBounds.x, background: "white", border: "1px solid black", borderRadius: 16, padding: "1rem", }} > <button onClick={() => setIsOpen(false)}>X</button> This is a context menu </div> )} </> );}
Challenge
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 track when an element is being hovered, we can use the mouseover
HTML event.
To make sure the user has been hovering for at least 1 second, we can add a setTimeout
to delay the display of the tooltip.
Remember to clean up the
setTimeout
when the component is unrendered!
- React
- Angular
- Vue
function App() { const buttonRef = useRef(); const mouseOverTimeout = useRef(); const [tooltipMeta, setTooltipMeta] = useState({ show: false, }); const onMouseOver = () => { mouseOverTimeout.current = setTimeout(() => { setTooltipMeta({ show: true, }); }, 1000); }; useEffect(() => { return () => { clearTimeout(mouseOverTimeout.current); }; }, []); return ( <div style={{ padding: "10rem" }}> <button onMouseOver={onMouseOver} ref={buttonRef}> Send </button> {tooltipMeta.show && <div>This will send an email to the recipients</div>} </div> );}
Step 2: Remove the Element When the User Stops Hovering
Now that we have our tooltip showing up when we'd expect it, let's remove it when we stop hovering on the button element.
To do this, we'll use the mouseleave
HTML event to set show
to false
and cancel the timer to show the tooltip if the event is active.
- React
- Angular
- Vue
function App() { const buttonRef = useRef(); const mouseOverTimeout = useRef(); const [tooltipMeta, setTooltipMeta] = useState({ show: false, }); const onMouseOver = () => { mouseOverTimeout.current = setTimeout(() => { setTooltipMeta({ show: true, }); }, 1000); }; const onMouseLeave = () => { setTooltipMeta({ show: false, }); clearTimeout(mouseOverTimeout.current); }; useEffect(() => { return () => { clearTimeout(mouseOverTimeout.current); }; }, []); return ( <div style={{ padding: "10rem" }}> <button onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} ref={buttonRef} > Send </button> {tooltipMeta.show && <div>This will send an email to the recipients</div>} </div> );}
Step 3: Placing the Tooltip above the Button
To place the tooltip above the button, we'll measure the button's position, height, and width using an element reference and the HTMLElement
's method of getBoundingClientRect
.
We'll then use this positional data alongside the CSS position: fixed
to position the tooltip to be placed 8px
above the y
axis of the button:
- React
- Angular
- Vue
function App() { const buttonRef = useRef(); const mouseOverTimeout = useRef(); const [tooltipMeta, setTooltipMeta] = useState({ x: 0, y: 0, height: 0, width: 0, show: false, }); const onMouseOver = () => { mouseOverTimeout.current = setTimeout(() => { const bounding = buttonRef.current.getBoundingClientRect(); setTooltipMeta({ x: bounding.x, y: bounding.y, height: bounding.height, width: bounding.width, show: true, }); }, 1000); }; const onMouseLeave = () => { setTooltipMeta({ x: 0, y: 0, height: 0, width: 0, show: false, }); clearTimeout(mouseOverTimeout.current); }; useEffect(() => { return () => { clearTimeout(mouseOverTimeout.current); }; }, []); return ( <div style={{ padding: "10rem" }}> {tooltipMeta.show && ( <div style={{ position: "fixed", top: `${tooltipMeta.y - tooltipMeta.height - 8}px`, }} > This will send an email to the recipients </div> )} <button onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} ref={buttonRef} > Send </button> </div> );}
Step 4: Centering the Tooltip Horizontally
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>
adisplay: flex
element withjustify-content: center
CSS applied to center all children - Allowing overflow inside the
div
usingoverflow: visible
- Placing our tooltip's text inside the
<div>
withwhite-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>
Let's implement this within our frameworks:
- React
- Angular
- Vue
function App() { const buttonRef = useRef(); const mouseOverTimeout = useRef(); const [tooltipMeta, setTooltipMeta] = useState({ x: 0, y: 0, height: 0, width: 0, show: false, }); const onMouseOver = () => { mouseOverTimeout.current = setTimeout(() => { const bounding = buttonRef.current.getBoundingClientRect(); setTooltipMeta({ x: bounding.x, y: bounding.y, height: bounding.height, width: bounding.width, show: true, }); }, 1000); }; const onMouseLeave = () => { setTooltipMeta({ x: 0, y: 0, height: 0, width: 0, show: false, }); clearTimeout(mouseOverTimeout.current); }; useEffect(() => { return () => { clearTimeout(mouseOverTimeout.current); }; }, []); return ( <div style={{ padding: "10rem" }}> {tooltipMeta.show && ( <div style={{ overflow: "visible", position: "fixed", top: `${tooltipMeta.y - tooltipMeta.height - 8}px`, display: "flex", justifyContent: "center", width: `${tooltipMeta.width}px`, left: `${tooltipMeta.x}px`, }} > <div style={{ whiteSpace: "nowrap", }} > This will send an email to the recipients </div> </div> )} <button onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} ref={buttonRef} > Send </button> </div> );}
Step 5: Adding Polish
Our tooltip works now! But, being honest, it's a bit plain-looking without much styling.
Let's fix that by adding:
- Background colors
- A dropdown arrow indicating the location of the element the tooltip is for
While the first item can be added using some background-color
CSS, the dropdown arrow is a bit more challenging to solve.
The reason a dropdown arrow is more challenging is that CSS typically wants all elements to be represented as a square — not any other shape.
However, we can use this knowledge to use a square and trick the human eye into thinking it's a triangle by:
- Rotating a square 45 degrees to be "sideways" using CSS'
transform
- Adding color to the square using
background-color
- Positioning the square to only show the bottom half using
position: absolute
and a negative CSStop
value - Placing it under the tooltip background using a negative
z-index
Let's build it!
- React
- Angular
- Vue
function App() { const buttonRef = useRef(); const mouseOverTimeout = useRef(); const [tooltipMeta, setTooltipMeta] = useState({ x: 0, y: 0, height: 0, width: 0, show: false, }); const onMouseOver = () => { mouseOverTimeout.current = setTimeout(() => { const bounding = buttonRef.current.getBoundingClientRect(); setTooltipMeta({ x: bounding.x, y: bounding.y, height: bounding.height, width: bounding.width, show: true, }); }, 1000); }; const onMouseLeave = () => { setTooltipMeta({ x: 0, y: 0, height: 0, width: 0, show: false, }); clearTimeout(mouseOverTimeout.current); }; useEffect(() => { return () => { clearTimeout(mouseOverTimeout.current); }; }, []); return ( <div style={{ padding: "10rem" }}> {tooltipMeta.show && ( <div style={{ display: "flex", overflow: "visible", justifyContent: "center", width: `${tooltipMeta.width}px`, position: "fixed", top: `${tooltipMeta.y - tooltipMeta.height - 16 - 6 - 8}px`, left: `${tooltipMeta.x}px`, }} > <div style={{ whiteSpace: "nowrap", padding: "8px", background: "#40627b", color: "white", borderRadius: "16px", }} > This will send an email to the recipients </div> <div style={{ height: "12px", width: "12px", transform: "rotate(45deg) translateX(-50%)", background: "#40627b", bottom: "calc(-6px - 4px)", position: "absolute", left: "50%", zIndex: -1, }} /> </div> )} <button onMouseOver={onMouseOver} onMouseLeave={onMouseLeave} ref={buttonRef} > Send </button> </div> );}