Track Page Visibility in React using Render Props

We will create a simple reusable React component that tracks Page Visibility State

When creating a web application you may come across situations where you need to track the current visibility state of the app. You may need to play/pause a video or animation effect, throttle some performance intensive work or simply track the user's behaviour for analytics based on whether the browser tab is active or not. Now, this feature seems pretty simple to implement until you actually try to implement it for the first time. It turns out tracking whether the user is actively interacting with the web application or not can be quite tricky.

There is a Page Visibility API which works fine for most cases but it does not handle all the possible cases of browser tab inactivity. The Page Visibility API that sends a visibilitychange event to let listeners know that the visible state of the page has changed, has some irregularities. It doesn't fire the event in some of the cases even when the window or concerned browser tab is out of sight or out of focus. To handle some of these edge cases, we need to use a combination of focus and blur event listeners on both the document and the window element. You can find a detailed discussion about it here.

We will implement the workaround logic described in the tutorial mentioned above in a small React app. Don't worry you can read it later, we will explain every aspect of the logic that we will be using.

If you want to dive into the code or watch the demo, it is available on Codesandbox as well as GitHub.

https://codesandbox.io/s/81x9n78qmj

Getting Started

We will use Codesandbox to bootstrap our React application (you can use create-react-app as well). We will create a small app that will have an HTML5 Video element that will play only when the browser tab is in focus or active otherwise it will be paused automatically. We are using a video because it will make testing our app's functionality easy.

Let's start by creating the simplest piece i.e. the Video component. It will be a simple component that will receive a Boolean props named active and a String props named src that will hold the URL for the video. If the active props is true then we will play the video otherwise we will pause it.

Video.js

import React, { Component } from "react";

class Video extends Component {
  videoRef = React.createRef();

  // Safari won't allow playing the video without user interaction
  componentDidUpdate() {
    const video = this.videoRef.current;
    if (this.props.active) video.play();
    else video.pause();
  }

  render() {
    return (
      <video autoPlay ref={this.videoRef}>
        <source type="video/mp4" src={this.props.src} />
      </video>
    );
  }
}

export default Video;

We will create a simple React class component. We will render a simple video element with its source set to the URL passed using the src props and use React's new ref API to attach a ref on the video DOM node. We will set the video to autoplay assuming when we start the app the page will be active. One thing to note here is Safari now doesn't allow auto-playing media elements without user interaction. The componentDidUpdate lifecycle method is very handy in handling side effects when a component's props change. Therefore, here we will use this lifecycle method to play and pause the video based on the current value of this.props.active.

Browser vendor prefix differences are very annoying to deal with when using certain APIs and the Page Visibility API is certainly one of them. Therefore, we will create a simple utility function that will handle these differences and return us the compatible API based on the user's browser in a uniform manner. We will create and export this small function from pageVisibilityUtils.js under the src directory.

pageVisibilityUtils.js

export const pageVisibilityApi = () => {
  let hidden, visibilityChange;
  if (typeof document.hidden !== "undefined") {
    // Opera 12.10 and Firefox 18 and later support
    hidden = "hidden";
    visibilityChange = "visibilitychange";
  } else if (typeof document.msHidden !== "undefined") {
    hidden = "msHidden";
    visibilityChange = "msvisibilitychange";
  } else if (typeof document.webkitHidden !== "undefined") {
    hidden = "webkitHidden";
    visibilityChange = "webkitvisibilitychange";
  }

  return { hidden, visibilityChange };
};

In this function, we will utilize simple if-else statement based control flow to return the browser-specific API. You can see we attached the ms prefix for Internet Explorer and webkit prefix for Webkit browsers. We will store the correct API in hidden and visibilityChange string variables and return them from the function in the form of a simple object. Lastly, we will export the function.

Next, we move onto our main component. We will encapsulate all of our Page Visibility tracking logic in a reusable React class component by leveraging the Render Props pattern. We will create a class component called VisibilityManager. This component will handle the adding and removing of all the DOM based event listeners.

We will start by importing the utility function we created earlier and invoking it to get the correct browser specific APIs. Then, we will create the React component and initialize its state with a single field isVisible set to true. This Boolean state field will be responsible for reflecting our page visibility state. In the component's componentDidMount lifecycle method we will attach an event listener on the document for the visibilitychange event with the this.handleVisibilityChange method as its handler. We will also attach event listeners for the focus and blur events on the document as well as the window element. This time we will set this.forceVisibilityTrue and this.forceVisibilityFalse as the handlers for the focus and blur events respectively.

VisibilityManager.js

import { Component } from "react";

import { pageVisibilityApi } from "../pageVisibilityUtils";

const { hidden, visibilityChange } = pageVisibilityApi();

class VisibilityManager extends Component {
  state = {
    isVisible: true
  };

  componentDidMount() {
    document.addEventListener(visibilityChange, this.handleVisibilityChange, false);

    document.addEventListener("focus", this.forceVisibilityTrue, false);
    document.addEventListener("blur", this.forceVisibilityFalse, false);

    window.addEventListener("focus", this.forceVisibilityTrue, false);
    window.addEventListener("blur", this.forceVisibilityFalse, false);
  }

  handleVisibilityChange = forcedFlag => {
    // this part handles when it's triggered by the focus and blur events
    if (typeof forcedFlag === "boolean") {
      if (forcedFlag) {
        return this.setVisibility(true);
      }
      return this.setVisibility(false);
    }

    // this part handles when it's triggered by the page visibility change events
    if (document[hidden]) {
      return this.setVisibility(false);
    }
    return this.setVisibility(true);
  };

  forceVisibilityTrue = () => {
    this.handleVisibilityChange(true);
  };

  forceVisibilityFalse = () => {
    this.handleVisibilityChange(false);
  };

  setVisibility = flag => {
    this.setState(prevState => {
      if (prevState.isVisible === flag) return null;
      return { isVisible: flag };
    });
  };

  componentWillUnmount() {
    document.removeEventListener(visibilityChange, this.handleVisibilityChange, false);

    document.removeEventListener("focus", this.forceVisibilityTrue, false);
    document.removeEventListener("blur", this.forceVisibilityFalse, false);

    window.removeEventListener("focus", this.forceVisibilityTrue, false);
    window.removeEventListener("blur", this.forceVisibilityFalse, false);
  }

  render() {
    return this.props.children(this.state.isVisible);
  }
}

export default VisibilityManager;

Now, we will then create the handleVisibilityChange method that will accept a single argument forcedFlag. This forceFlag argument will be used to determine whether the method is called because of the visibilitychange event or the focus or blur events. This is so because the forceVisibilityTrue and forceVisibilityFalse methods do nothing but call the handleVisibilityChange method with true and false value for the forcedFlag argument. Inside the handleVisibilityChange method, we first check whether the forcedFlag argument value is a Boolean (this is because if it is called from the visibilitychange event handler than the argument passed on will be a SyntheticEvent object). If it is a Boolean then we check if it's true or false. When it's true we called the setVisibility method with true otherwise we call the method with false as an argument. The setVisibility method leverages this.setState method to update isVisible field's value in the component's state. But, if the forcedFlag is not a Boolean, then we check the hidden attribute value on the document and call the setVisibility method accordingly. This wraps up our Page Visibility State tracking logic.

To make the component reusable in nature we use the Render Props pattern i.e. instead of rendering a component from the render method, we invoke this.props.children as a function with this.state.isVisible.

index.js

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

import VisiblityManager from "./components/VisibilityManager";
import Video from "./components/Video";

const App = () => (
  <VisiblityManager>
    {isVisible => <Video active={isVisible} src="https://www.w3schools.com/html/mov_bbb.mp4" />}
  </VisiblityManager>
);

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Lastly, we mount our React app to the DOM in our index.js file. We import our two React components VisibilityManager and Video and create a small functional React component App by composing them. We pass a function as the children of the VisibilityManager component that accepts isVisible as an argument and passes it to the Video component in its return statement. We also pass a video URL as src props to the Video component. This is how we consume the Render Props based VisiblityManager component. Finally, we use ReactDOM.render method to render our React app on the DOM node with id "root".

Conclusion

Modern browser APIs are getting really powerful and can be used to do amazing things. Most of these APIs are imperative in nature and can be tricky to work with sometimes in React's declarative paradigm. Using powerful patterns like Render Props to wrap these APIs into their own reusable React components can be very useful.