Directives

January 6, 2025

6,451 words

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

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:

const useStyleBackground = () => {	const ref = (el) => {		el.style.background = "red";	};	return { ref };};const App = () => {	const { ref } = useStyleBackground();	return <button ref={ref}>Hello, world</button>;};

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.

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 run localEl.focus() inside of the ref function.

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.

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

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:

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

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:

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

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

Challenge

In our "Portals" chapter, we implemented a tooltip that used portals to avoid issues with the stacking context:

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

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:

A tooltip with less fancy styling immediately below the button

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.

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>	);};
Previous articleShared Component Logic
Next article Accessing Children

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.