React RiddlesThe Mysterious Case of the Untracked Clicks

Johannes KettmannPublished on 

In this series, you confront real-world React mysteries. It's an opportunity to test your skills and flex your debugging muscles on realistic problems that you could encounter on the job and during job interviews.

Recently, I encountered a perplexing bug affecting our analytics data on my day job. We noticed something strange - in certain instances, our tracking events would fire up perfectly, but at other times, they wouldn't activate at all. It was a mysterious problem that needed solving, and it all started with the way we were tracking clicks on a button.

If you’re rather a video person you can watch the complete review and refactoring session here.

The Mystery

Allow me to share an example of our button tracking code for better clarity (you can find the complete code here on StackBlitz):

import { useRef } from "react";
import { useTracking } from "./use-tracking";
import { Button } from "./button";
import { ReactComponent as EditIcon } from "./edit.svg";
export function User() {
const containerRef = useRef<HTMLDivElement>(null);
useTracking(containerRef, "user-profile", { authStatus: "authenticated" });
return (
<div ref={containerRef}>
<Button data-tracking-id="edit">Edit</Button>
<Button data-tracking-id="edit-icon" icon={<EditIcon />} />
</div>
);
}

This tracking system relied on a useTracking hook. To keep things simple, I'll share a more straightforward version of it:

import { MutableRefObject, useEffect } from "react";
export function useTracking(
containerRef: MutableRefObject<HTMLElement | null>,
containerId: string,
trackingParams?: Record<string, string>
) {
useEffect(() => {
// container not yet mounted
if (!containerRef.current) {
return;
}
const container = containerRef.current;
// the callback that sends the tracking event
const listener = (event: Event) => {
// get the tracking id from the data attribute (e.g. data-tracking-id="button-1")
const target = event.target as HTMLElement;
const trackingId = target.dataset.trackingId;
// Send tracking event
if (trackingId) {
console.log(`Tracking ${containerId} ${trackingId}`);
window.tracking.push([containerId, trackingId, trackingParams]);
}
};
container.addEventListener("click", listener);
return () => {
container.removeEventListener("click", listener);
};
}, [containerRef, containerId, trackingParams]);
}

This system worked like a charm, that is, until we started incorporating more icon buttons:

export function User() {
const containerRef = useRef<HTMLDivElement>(null);
useTracking(containerRef, "user-profile", { authStatus: "authenticated" });
return (
<div ref={containerRef}>
<Button data-tracking-id="edit">Edit</Button>
<Button data-tracking-id="edit-icon" icon={<EditIcon />} />
</div>
);
}

And then, our mysterious bug reared its ugly head: why on earth was the tracking event firing for the text button but not for the icon button?

Let's dig into this curious issue in the next section.

A Quick Note Before We Begin

As you venture into this blog post, I encourage you to treat it not just as a read, but as an interactive exercise. As we journey through a real-world bug in our code, try to come up with your own solutions before you continue to the next section. Trust me; it's more fun and informative this way!

This isn't just a casual recommendation. The problem at hand is similar to those you might encounter during a job interview for a developer role. Working through it on your own will give you a taste of real-world challenges and offer valuable practice for such scenarios. It's not just about the specific bug; it's also about flexing those problem-solving muscles and honing your analytical skills.

Moreover, this exercise even incorporates a touch of algorithmic thinking - a vital skill in the realm of programming. So put on your thinking caps and dive right in. Let's crack this bug together!

Debugging the Issue

Debugging in the world of programming can be a complex task, but the most straightforward way to begin here is to set a breakpoint using Chrome dev tools. This allows us to observe the behavior of our code while it's running and catch any irregularities.

When we click on the “Edit” text button, we see that the trackingId is indeed defined, which enables the tracking event to fire as expected.

However, a different story unfolds when we click on the icon button: the trackingId comes out as undefined.

So, why isn’t the trackingId defined when clicking on the icon button?

Here's a bit of a mystery for you. Can you figure out the answer before moving on? Here's the source code.

Unmasking the culprit

It took me a bit to grasp this during my investigation, but the answer lies in the specifics of the click event. When we click on the SVG icon, we're actually interacting with the SVG element itself.

But here's the catch: our tracking ID isn't set on the SVG element - it's set on the button, which is the parent element.

So, how can we solve this?

I encourage you to give it a shot before moving forward.

This could well be one of these real-world algorithm questions you’ll ask in a job interview!

First, let's understand how the useTracking hook works

In our component, we pass a reference to the container element to the hook.

Within the hook, we attach a click listener to this container.

Now, it's crucial to note that this listener isn't triggered only when we click on the container element itself. Due to the bubbling nature of events in JavaScript, this listener is activated for any clicks on its child elements. So, if we click one of the buttons, the click event propagates to the container, triggering the listener. Inside this listener, we retrieve the data attribute trackingId from the event target and only fire the tracking event if trackingId is defined.

Here's where the issue arises: when we click the text button, the event target is the button element, which possesses a tracking ID. However, when we click on the icon inside the button, the event target becomes the svg element, which doesn't have a tracking ID.

Now that we've outlined the issue, we can move on to the solution. But are you sure you don't want to give it a shot on your own first? Again, here's the source code.

The solution: Recursion to the rescue

The first thought you might have is to check both the event target and its parent element for a tracking ID. You're on the right track, but there's a twist: if you click on the icon button often enough, you might hit the path element within the svg. In this case, the parent’s parent would be the element with the tracking ID.

We want to create a tracking hook that works universally, even with complex elements like clickable cards that have many nested child elements. The solution? Use a recursive approach to explore each parent element of the original event target until we locate an element with a data-tracking-id attribute.

function getTrackingId(element: HTMLElement | null) {
// Stop condition: the previous element doesn't have a parent.
// We're at the root of the tree.
if (!element) {
return null;
}
const trackingId = element.dataset.trackingId;
// Stop condition: we return when the element has a tracking ID
if (trackingId) {
return trackingId;
}
// continue recursively traversing the tree
return getTrackingId(element.parentElement);
}

Now, we can use this function to retrieve the tracking ID from any parent element.

export function useTracking(
...
useEffect(() => {
const listener = (event: Event) => {
const target = event.target as HTMLElement;
// use the new recursive function to get the tracking ID from
// any parent element
const trackingId = getTrackingId(target);
if (trackingId) {
console.log(`Tracking ${containerId} ${trackingId}`);
window.tracking.push([containerId, trackingId, trackingParams]);
}
};
...

And voila, it works!

However, this recursive approach does come with a minor drawback. Suppose we click on a child of a tracking container that we don't want to track. In that case, we'll still traverse all the parent elements even though we're not interested in this click event. For some apps, this might cause performance issues. However, for most apps, this should be manageable, as there typically aren't thousands of parent elements to go through.

A Sneak Peek at What's Next

Just when you think we've squashed that bug and our code is as clean as a whistle, a wild mystery appears! Yes, you heard it right. Hidden within the nooks and crannies of our code is another bug, patiently waiting to be discovered.

But worry not! We're not going to leave you hanging. This intrigue sets the stage for our next blog post, where we'll embark on yet another bug-hunting adventure. We'll take our detective hats out once again and get down to dissecting this elusive code miscreant.

So, stay tuned and gear up for another round of digital troubleshooting. We'll dig deeper, learn more, and emerge victorious once again in our journey through the fascinating world of code! Until then, happy coding!

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.
Improve your health as a software developer
After years working in front of a screen, my health was at an all-time low. I gained weight, my back hurt, and I was constantly tired. I knew I had to make a change. That's why I started Office Walker. I want to help you improve your health and productivity as a software developer.