Element Reference

January 6, 2025

6,902 words

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:

When the user right-clicks it shows a context menu of options like "Cut", "Copy", and "Paste"

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
/** * 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



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 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>;};

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.

useState refs

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 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.

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 useEffects 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 with useRef 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.

A mockup of a mail application that has a button to scroll to the top of the messages list

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'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>	);}

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:

  1. Focusing the dropdown element when opened
  2. Closing the context menu when the user clicks elsewhere
With the context menu open, when you left-click outside of the bounds of the context menu, it will close it

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:

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:

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.

Hovering over a "send" button will show an alert above the button saying, "This will send an email to the recipients."

To do this, we'll need to consider a few things:

  1. How to track when the user has hovered over an element for a second or longer
  2. How to remove the popup when the user has moved their mouse
  3. Make sure the tooltip is positioned above the button
  4. Make sure the tooltip is horizontally centered
  5. 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!

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.

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:

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> 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:

The divs position is above the button, demonstrated by the dev tools preview of the div's position

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:

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:

  1. Background colors
  2. 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:

  1. Rotating a square 45 degrees to be "sideways" using CSS' transform
  2. Adding color to the square using background-color
  3. Positioning the square to only show the bottom half using position: absolute and a negative CSS top value
  4. Placing it under the tooltip background using a negative z-index
We start with a black square, rotate it 45 degrees, then color it blue. We then place it in the middle bottom of the tooltip with half of the arrow sticking out. Finally we move the square under the tooltip background

Let's build it!

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>	);}
Previous articlePassing Children
Next article Component Reference

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.