SI

[ The `useActivityMonitor` Hook! ]

May 30, 2019

🕰 8 min read

Cover Photo Credit: Photo by Drew Graham on Unsplash

tl;dr

  • I created a custom hook - useActivityMonitor that monitors a user's activity and prompts an idle user (which you can play around with here:)

Edit useActivityMonitor Hook

  • How do I use the useActivityMonitor Hook? You just need to pass an object to the hook with a timeout in milliseconds and my hook will provide you with a boolean - promptUser which indicates if timeout amount of idle time has passed, restoreSession - a callback function to reset the internal timer and set promptUser to false, and killSession - a callback function to set promptUser to false without restarting the internal timer. For example,
const { promptUser, restoreSession, killSession } = useActivityMonitor({
  timeout: 5000, // prompt the user after 5 seconds of idle time
})
  • If you need to pass an object into the useEffect dependency array, you need to make sure to make the deep comparison yourself. React will only do a direct comparison instead of a shallow one. You can do this by saving the object in a mutable ref and comparing the object saved in the ref with the most recent object using isEqual from lodash.

The Problem

I came across an interesting problem at work - I was tasked to write a feature to prompt an idle user with a dialog saying "Are you still there? 👀" with the option to "continue the session" or "log out" of the application completely. Of course, my first thought was to build out a custom hook to do this.

My Thought Process

I had a general idea of what I needed to do:

  • create a hook that takes an object as an argument with three properties _ [optional] events an array of case sensitive event names as strings (defaults to [“load”, “mousemove”, “mousedown”, “click”, “scroll”, “keypress”]) _ timeout a number in milliseconds * [optional] onKillSession a callback function to be invoked after timeout amount of time has passed.
  • and returns three useful properties: _ promptUser - boolean indicating whether or not the timer has completed. _ restoreSession - callback function that sets promptUser to false and resets the internal timer * killSession - callback function that sets promptUser to false but does not restart the internal timer.
  • I also would need to create an effect that would add a bunch of event listeners to the window and simultaneously start an internal timer which we will save into a mutable ref.
  • Lastly, we need to return a callback function from the useEffect to clean up all event listeners and clearTimeout the internal timer.

First (Incomplete) Prototype

function useActivityMonitor({
  timeout,
  onKillSession = () => ({}),
  events = ['load', 'mousemove', 'mousedown', 'click', 'scroll', 'keypress'],
}) {
  const [promptUser, setPromptUser] = React.useState(false)
  const timerRef = React.useRef(null)

  React.useEffect(
    () => {
      function handlePromptUser() {
        // prompt user that the time is up!
        setPromptUser(true)
        // remove all event listeners
        for (let event of events)
          window.removeEventListener(event, restoreSession)
      }

      function restoreSession() {
        // set promptUser to false
        setPromptUser(false)
        // clear old timer
        clearTimeout(timerRef.current)
        // set new timer
        timerRef.current = setTimeout(handlePromptUser, timeout)
      }

      // add all event listeners
      for (let event of events) window.addEventListener(event, restoreSession)
      // set timer
      timerRef.current = setTimeout(handlePromptUser, timeout)

      // cleanup effect
      return () => {
        for (let event of events)
          window.removeEventListener(event, restoreSession)
        clearTimeout(timerRef.current)
      }
    },
    [timeout, events]
  )

  return { promptUser }
}

The Bug 🐜

It seemed like everything was working but, of course, there was a bug.

the pesky bug caught in the debugger.

Do you see the bug in the gif above? My effect is run twice! Therefore, the event listeners from the first effect are successfully removed, however, because the effect is running a second time, the event listeners are re-added to the window. We then get a bug like this:

demonstration of the bug in the app.

If you’ve been using React Hooks for awhile you might have noticed my mistake. I was passing events into the useEffect dependency array however, this doesn’t work because React is “not going to do a shallow comparison. It will actually do a direct comparison” (Kent C. Dodds,) of the items in the dependency array. Therefore, in my code above, the events would always be considered “new” to React since direct comparison of two array objects would always be false. I found a very helpful video on Egghead by @kentcdodds that covered my exact situation - https://bit.ly/2X86dfI. The key is to save the previous events array into a ref and then make a deep comparison using isEqual from lodash with the current events array. If they are different we will rerun the entire effect, otherwise return from the useEffect prematurely.

import isEqual from 'lodash/isEqual' // 👈
// ...
function useActivityMonitor({
  timeout,
  onKillSession = () => ({}),
  events = ['load', 'mousemove', 'mousedown', 'click', 'scroll', 'keypress'],
}) {
  // ...
  const prevEventsRef = React.useRef(null) // 👈 open up a ref
  // ...
  React.useEffect(
    () => {
      // deep comparison of the `events` array
      if (isEqual(events, prevEventsRef.current)) return
      // ...
    },
    [timeout, events]
  )

  // on each render, save `events` into a ref // 👈
  React.useEffect(() => {
    prevEventsRef.current = events
  })

  return { promptUser }
}

A somewhat bug-free version of useActivityMonitor hook.

This is great, but, what if the user of my hook decides to increment or decrement the timeout variable in my hook while keeping the events array the same? Our effect would not be reapplied since our effect would stop here:

if (isEqual(events, prevEventsRef.current)) return

We need to open up another ref to hold on to the previous timeout variable and check if the previous timeout is equal to the current timeout as well.

// ...
function useActivityMonitor({
  timeout,
  onKillSession = () => ({}),
  events = ["load", "mousemove", "mousedown", "click", "scroll", "keypress"]
}) {
  // ...
  const prevTimeoutRef = React.useRef(null); // 👈 create another ref
  // ...
  React.useEffect(() => {
    // if the events array AND timeout is the same don't rerun my effect! 🙏
    if (isEqual(events, prevEventsRef.current) && timeout === prevTimeoutRef.current) return;
    // ...
  }, [timeout, events]);

  // on each render, save `events` and `timeout` into a ref
  React.useEffect(() => {
    // ...
    prevTimeoutRef.current = timeout; // 👈 save previous timeout
  });

  return { promptUser };

Last two important features

Great, we destroyed those pesky bugs, but, we are not done! We still need to provide the user with two callback functions from our hook:

  1. restoreSession which will set promptUser to false and reset the internal timer. (We’ve already defined this as a function inside of the useEffect. Let’s use another ref to capture the function to be used outside of the useEffect scope.)
  2. killSession which will set promptUser to false and invoke the provided onKillSession callback function.
function handleKillSession() {
  setPromptUser(false)
  onKillSession()
}

Putting it all together we have something like this:

import React from 'react'
import isEqual from 'lodash/isEqual'

export function useActivityMonitor({
  timeout,
  onKillSession = () => ({}),
  events = ['load', 'mousemove', 'mousedown', 'click', 'scroll', 'keypress'],
}) {
  const [promptUser, setPromptUser] = React.useState(false)
  const timerRef = React.useRef(null)
  const prevEventsRef = React.useRef(null)
  const prevTimeoutRef = React.useRef(null)
  const restoreSessionRef = React.useRef(() => ({}))

  function handleKillSession() {
    setPromptUser(false)
    onKillSession()
  }

  // if the events array AND timeout is the same don't rerun 	my effect! 🙏
  React.useEffect(
    () => {
      if (
        isEqual(events, prevEventsRef.current) &&
        prevTimeoutRef.current === timeout
      )
        return

      function restoreSession() {
        // set promptUser to false
        setPromptUser(false)
        // clear old timer
        clearTimeout(timerRef.current)
        // set new timer
        timerRef.current = setTimeout(handlePromptUser, timeout)
      }

      function handlePromptUser() {
        // prompt user that the time is up!
        setPromptUser(true)
        // remove all event listeners
        for (let event of events)
          window.removeEventListener(event, restoreSession)
      }

      restoreSessionRef.current = restoreSession

      // add all event listeners
      for (let event of events) window.addEventListener(event, restoreSession)
      // set timer
      timerRef.current = setTimeout(handlePromptUser, timeout)

      // cleanup effect
      return () => {
        for (let event of events)
          window.removeEventListener(event, restoreSession)
        clearTimeout(timerRef.current)
      }
    },
    [timeout, events]
  )

  // on each render, save `events` and `timeout` into a ref
  React.useEffect(() => {
    prevEventsRef.current = events
    prevTimeoutRef.current = timeout
  })

  return {
    promptUser,
    restoreSession: restoreSessionRef.current,
    killSession: handleKillSession,
  }
}

One last thing I would add is to make the restoreSession function a debounced function so that it doesn’t rerun so many times (I leave this as an exercise for the curious reader.)

Conclusion

Writing the useActivityMonitor has taught me a ton about building my own custom hooks. I recommend trying to write your own version of this hook. You’ll definitely learn a ton! You can play around with my final version the hook here:

Edit useActivityMonitor Hook

I am always looking for constructive feedback on my work, so, if you have any feedback to make this hook even better, please comment down below. I would also love to see your custom hooks as well. Thank you for your time ✌️!


a not so professional head shot.

Scott Iwako

👋 Hello, thanks for the read! If you found my work helpful, have constructive feedback, or just want to say hello, connect with me on social media. Thanks in advance!