DEV Community

Cover image for Using JS Intersection Observer API to track user footprints
Harshit Thukral
Harshit Thukral

Posted on

Using JS Intersection Observer API to track user footprints

Understanding user behavior for your web app to find out where the disconnect is and which of your features is giving a hard time to your users is no more a secondary thing. If you try googling for some good ready to integrate solutions to track users, you will find some big and established players like Google Analytics and Mixpanel who also serve you with exceptional metrics and dashboards based on the data you publish to them. Now, your respective teams can analyze this data and zoom into the actual pain points and gaps.

But what if you had a use-case like we did, where a user had to pay for each visit depending on the time they spent and the features they strolled over during their time on the platform. The question that comes out, is this data first of all exposed and secondly reliable enough to cut someone a ticket? The answer was NO! All because integrating most of these libraries effectively requires a lot of SDK calls to be integrated across your whole app like landmines. So without boring you any further with the back story of why let's jump to..

After weighing the effectiveness and integration efforts we decided to rely on the browser's IntersectionObserver API to rescue us. It lets you observe elements in your DOM tree and dispatches an event when one of those elements enters or exits the viewport. Let's put some code where my mouth is.

Tracking Service

First, we needed a service that can work as a singleton to observe and track different components in your viewport and also independently integrate with the backend service.

function createObserver(options = {}) {
  // you can understand the meaning of each options values here
  // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
  let options = {
    root: document.querySelector("window"),
    rootMargin: "0px",
    ...options
  };

  let observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      // do something when some target changes state (enters or exits viewport)
    });
  }, options);

  return {
    observe: function({ id, details, element }) {
      observer.observe(element);
    },
    unobserve: function({ id, details, element }) {
      observer.unobserve(element);
    },
  };
}

export default createObserver();
Enter fullscreen mode Exit fullscreen mode

So, what's happening here is, we created function createObserver an abstraction that exposes two fundamental methods:

observe: this will help us register our components/nodes to the observer. So that it can start tracking and notify us once the state of the element changes.
unobserve: Just opposite to the observe method. its job is to de-register the element from the observer and stop if there's any already running timer.

Now, these two simple methods work for most of the cases, but there's one particular case, when the whole app un-mounts and we still had few running timers. In that case, we need to maintain an in-memory map of all elements being tracked and expose another method unobserveAll that would just unobserve all records before going down.

function createObserver(options = {}) {
  const observingTargets = {};

  let options = {
    // ...
  };

  let observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      const id = entry.target.getAttribute("data-observer-id");
      if(observingTargets[id].isIntersecting != entry.isIntersecting) {
        observingTargets[id].isIntersecting = entry.isIntersecting;
        // toggle feature timer here (API)
      }
    });
  }, options);

  return {
    observe: function({ id, details, element }) {
      observingTargets[id] = {
        id,
        details,
        element
      };
      observer.observe(element);
    },
    unobserve: function({ id, details, element }) {
      observer.unobserve(element);
      // stop timer (API)
      delete observingTargets[id];
    },
    unobserveAll: function() {
      Object.keys(observingTargets).forEach(id => {
        this.unobserve(observingTargets[id]);
      });
    }
  };
}

export default createObserver();
Enter fullscreen mode Exit fullscreen mode

With the new code additions, we now have a map called observingTargets that holds all the elements under observation and their current state. When any of those elements change state, for each of them, we update the record, and a boolean isIntersecting property telling the current state. The only thing remaining now is to hit the backend service API to start/stop the timer. Let's add that as well and then we can rub our hands and integrate it with our react components.

function toggleTimer(payload) {
  // tell api about the state change
  return axios.post(`/timer/${payload.isIntersecting ? 'start' : 'stop'}`, payload.details)
}

function createObserver(options = {}) {
  const observingTargets = {};

  let options = {
    // ...
  };

  let observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      const id = entry.target.getAttribute("data-observer-id");
      if(observingTargets[id].isIntersecting != entry.isIntersecting) {
        observingTargets[id].isIntersecting = entry.isIntersecting;
        toggleTimer(observingTargets[id])
      }
    });
  }, options);

  return {
    observe: function({ id, details, element }) {
      observingTargets[id] = {
        id,
        details,
        element
      };
      observer.observe(element);
    },
    unobserve: function({ id, details, element }) {
      observer.unobserve(element);
      // overriding isIntersecting to handle the scenario 
      // in case app unmounts while element was still in the view port
      toggleTimer({...observingTargets[id], isIntersecting: false})
      delete observingTargets[id];
    },
    unobserveAll: function() {
      Object.keys(observingTargets).forEach(id => {
        this.unobserve(observingTargets[id]);
      });
    }
  };
}

export default createObserver();
Enter fullscreen mode Exit fullscreen mode

React HOC

On the UI component side of things, one has to handle three things:

  • Register itself to the observer service using observe and tell it to keep an eye on the component's intersection with the viewport.
  • Use unobserve function to de-register itself before un-mounting
  • Call unobserveAll function that will stop all the running timers once a user decides to leave your app.

The third one can be handled using the window's beforeunload event, which is called right before the tab unloads. So, for our React components, we'll be focussing on the first two.

HOC stands for Higher-Order Component. It's not something specific to React and lets you extend your components compositionally. As per official React documentation:

Concretely, a higher-order component is a function that takes a component and returns a new component.

So let's implement it:

import React from "react";
import ReactDOM from "react-dom";

import observer from "./observer";

const TrackedEl = function(ElToTrack, { id, ...details }) {
  return class extends React.Component {
    node = null;

    // providing the ability to override the id attribute before mounting.
    // this will be useful when you have multiple children of same type.
    id = this.props["data-observer-id"] || id;

    render() {
      return <ElToTrack {...this.props} />;
    }

    componentDidMount() {
      this.node = ReactDOM.findDOMNode(this);
      observer.observe({
        element: this.node,
        id: this.id,
        details: { id: this.id, ...details }
      });
    }

    componentWillUnmount() {
      observer.unobserve({
        element: this.node,
        id: this.id,
        details: { id: this.id, ...details }
      });
    }
  };
};

export default TrackedEl;
Enter fullscreen mode Exit fullscreen mode

What we implemented above is a function that returns our custom component, which renders the very same component in the render method that needs to be tracked and was passed to it as the first param. Additionally, it takes care of both registering(observe) and unregistering(unobserve) the actual DOM node using component lifecycle hooks.

PS: This can also re-written using a lot of React Hooks shorthands, you can try, but I find it easier to convey the message with the legacy API.

Now let's see how it can be used with our components:

const NormalReactComponent = (props) => {
  return (
    <div id={id}>
      Hey!, i'm being tracked
    </div>
  );
};

export default TrackedEL(NormalReactComponent, { id: 12 });
Enter fullscreen mode Exit fullscreen mode

That's it. Now, all we need to track our components is to wrap them with the TrackedEL HOC that will take care of all observing and un-observing logic using the functions exposed by the timer service created above.

So, now at the end of it, we have a well-crafted, easy to integrate, and extensible way to track our components and in-premise user data that can be relied upon as well as easily reconciled.

You can find the whole working code in this sandbox. Suggestions and corrections would be really appreciated.

Happy Tracking.

Top comments (0)