useSequentialTrackPageVisits() hook in React

We will create a custom hook in React that will help us track how many times have the page is viewed on client side (in the browser). You can use this to track how many times a component as mounted or any other activity.

To create this hook, we will rely on the localforage (indexDB), you can also use localStorage, it is upto you.

The logic is simple, we will use a key as a unique identifier to track the page or the component. Against this key we will add an entry into the localForage every time the component mounts using useEffect() hook.

Before adding the value to the storage, we will pull the last value and increment the count if it exists or make a fresh entry to it.

import { useEffect, useCallback, useState, useMemo } from 'react';

// Utils
import localforage from 'localforage';

export const useTrackPageViews = (key: string) => {
  const [pageViews, setPageViews] = useState(null);

  const getDefaultValue = useCallback(async () => {
    try {
      const value = await localforage.getItem(key);
      await localforage.setItem(key, (value ?? 0) + 1);
      setPageViews(value ?? 0);
    } catch (e) {
      setPageViews(0);
    }
  }, [setPageViews, key]);

  useEffect(() => {
    getDefaultValue();
  }, [key]);

  const _pageViews = useMemo(() => {
    return { pageViews };
  }, [pageViews]);

  return _pageViews;
};

Here we have created a function getDefaultValue() which is wrapped inside the useCallback() that will be updated only when the key changes.
Similar the value is wrapped inside the useMemo() as I am returning an object here which will trigger re-rendering as the referential equality will change. You can just return the variable as value and avoid using useMemo().

You can use useRef() as a variable rather than useState() to avoid triggering re-rendering.

Edge case

This implementation fails to handle a edge case where we use this hook to track component multiple component mounting which is part of the same page.

Example

const SubApp1 = (props) => {
  useTrackPageViews("learnersbucket");
  return <div style={props.style}>App 1</div>;
};

const SubApp2 = (props) => {
  useTrackPageViews("learnersbucket");
  return <div style={props.style}>App 2</div>;
};

export default function App() {
  const { pageViews } = useTrackPageViews("learnersbucket");

  return (
    <div>
      <SubApp1 />
      <SubApp2 />
      <p>Total page views {pageViews} from all the apps</p>
    </div>
  );
}

Here total page views will always remains the same, that is 1 on initial mount, that is because both the components reads from the localforage and get the same value. There is a race conditional handling that needs to be handled.

To fix this, we need to update our logic to sequentially track page visits, which is one after another. We can do so by creating a process queue that will read and write to the localforage one at a time, making sure the tracking is sequential.

export const useSequentialTrackPageViews = (key) => {
  // Use a ref to maintain the queue between renders
  const queueRef = useRef([]);
  const isProcessingRef = useRef(false);
  const [pageViews, setPageViews] = useState(null);

  const processQueue = useCallback(async () => {
    if (isProcessingRef.current || queueRef.current.length === 0) {
      return;
    }

    isProcessingRef.current = true;

    try {
      while (queueRef.current.length > 0) {
        const operation = queueRef.current[0];
        const { key, resolve, reject } = operation;
        try {
          const value = await localforage.getItem(key);
          const result = await localforage.setItem(key, (value ?? 0) + 1);
          resolve(result);
          setPageViews((value ?? 0) + 1);
        } catch (error) {
          setPageViews(0);
          reject(error instanceof Error ? error : new Error(String(error)));
        }
        queueRef.current.shift(); // Remove the processed operation
      }
    } finally {
      isProcessingRef.current = false;
    }
  }, []);

  const write = useCallback(
    (key) => {
      return new Promise((resolve, reject) => {
        queueRef.current.push({ key, resolve, reject });
        processQueue();
      });
    },
    [processQueue]
  );

  useEffect(() => {
    const asyncHelperFn = async () => {
      await write(key);
    };
    asyncHelperFn();
  }, [key]);

  const _pageViews = useMemo(() => {
    return { pageViews };
  }, [pageViews]);

  return _pageViews;
};

Testcase

Input:
const SubApp1 = (props) => {
  useSequentialTrackPageViews("learnersbucket");
  return <div style={props.style}>App 1</div>;
};

const SubApp2 = (props) => {
  useSequentialTrackPageViews("learnersbucket");
  return <div style={props.style}>App 2</div>;
};

export default function App() {
  const { pageViews } = useSequentialTrackPageViews("learnersbucket");

  return (
    <div>
      <SubApp1 />
      <SubApp2 />
      <p>Total page views {pageViews} from all the apps</p>
    </div>
  );
}

Output:
Total page views 2 from all the apps SubApp1

As the hook is used to track both.