Combining React Hooks With AppInsights

November 19, 2019 · 8 minute read · react · azure · javascript

The introduction of Hooks into React 16.8 changed the way that people thought about creating components to with within the React life cycle.

With the AppInsights React plugin you get a good starting point for integrating AppInsights but it uses a Higher Order Component (HOC) and a custom plugin, and I wanted something that'd integrate nicely into the Hooks pattern. So let's take a look at how you can go about building that.

React Context

Before creating my custom Hook I wanted to have a more React way in which I could access AppInsights, so let's create a React Context to use as a starting point. This will make the plugin available to all children components, and in theory, allow you to have different plugin configurations through different contexts (we won't try that out, but it's an idea that you may want to explore yourself). Admittedly, you don't need to create a Context to expose the plugin, but I just like the way the programmatic model comes together as a result of it.

We'll set up the AppInsights instance like we did in the first article of the series and export the reactPlugin from it as well (previously we'd only exported the AppInsights instance):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { ApplicationInsights } from "@microsoft/applicationinsights-web";
import {
    ReactPlugin,
    withAITracking
} from "@microsoft/applicationinsights-react-js";
import { globalHistory } from "@reach/router";

const reactPlugin = new ReactPlugin();
const ai = new ApplicationInsights({
    config: {
        instrumentationKey: process.env.APPINSIGHTS_KEY,
        extensions: [reactPlugin],
        extensionConfig: {
            [reactPlugin.identifier]: { history: globalHistory }
        }
    }
});
ai.loadAppInsights();

export default Component => withAITracking(reactPlugin, Component);
export const appInsights = ai.appInsights;
export { reactPlugin };

Now we can start creating our Context. Let's start with a new file called AppInsightsContext.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React, { createContext } from "react";
import { reactPlugin } from "./AppInsights";

const AppInsightsContext = createContext(reactPlugin);

const AppInsightsContextProvider = ({ children }) => {
    return (
        <AppInsightsContext.Provider value={reactPlugin}>
            {children}
        </AppInsightsContext.Provider>
    );
};

export { AppInsightsContext, AppInsightsContextProvider };

Great, you have the context ready for use and we have a component that sets up the reactPlugin for us when we use it. The last thing to do is to use it within our application somewhere.

Like in the first post, we'll update the Layout/index.js file so that we set the context up as high as we can:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const LayoutWithContext = ({ location, children }) => (
    <AppInsightsContextProvider>
        <>
            <Headroom
                upTolerance={10}
                downTolerance={10}
                style={{ zIndex: "20", height: "6.5em" }}
            >
                <Header location={location} />
            </Headroom>
            <Container text>{children}</Container>
            <Footer />
        </>
    </AppInsightsContextProvider>
);

🎉 Context is now in use and all children components are able to access it within our children components. And if we wanted to use the standard page interaction tracking of the React plugin we can combine this with the HOC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import React from "react";
import Headroom from "react-headroom";
import { Container } from "semantic-ui-react";
import Footer from "../Footer";
import Header from "../Header";
import "semantic-ui-css/semantic.min.css";
import { AppInsightsContextProvider } from "../../AppInsightsContext";
import withAppInsights from "../../AppInsights";

const Layout = withAppInsights(({ location, children }) => (
    <>
        <Headroom
            upTolerance={10}
            downTolerance={10}
            style={{ zIndex: "20", height: "6.5em" }}
        >
            <Header location={location} />
        </Headroom>
        <Container text>{children}</Container>
        <Footer />
    </>
));

const LayoutWithContext = ({ location, children }) => (
    <AppInsightsContextProvider>
        <Layout location={location} children={children} />
    </AppInsightsContextProvider>
);

export default LayoutWithContext;

Exposing Context as a Hook

The final thing we can do with our new Context-provided reactPlugin is to make it easier to access it and to do that we'll use the useContext Hook. To do this it's a simple matter of updating AppInsightsContext.js:

1
const useAppInsightsContext = () => useContext(AppInsightsContext);

Our first Hook is ready!

Creating a Hook for Tracking Events

With Context ready we can make some custom Hooks to use within our application. The Hook that we'll create is going to be a generic one so we can use it in multiple scenarios and work with the trackEvent method. Our Hook will take a few pieces of information, the reactPlugin instance to use, the name of the event (which will appear in AppInsights) and some data to track.

1
2
const useCustomEvent = (reactPlugin, eventName, eventData) => ({});
export default useCustomEvent;

Primarily, we'll need to use the useEffect Hook to call AppInsights, let's implement taht:

1
2
3
4
5
6
7
import { useEffect } from "react";
const useCustomEvent = (reactPlugin, eventName, eventData) => {
    useEffect(() => {
        reactPlugin.trackEvent({ name: eventName }, eventData);
    }, [reactPlugin, eventName, eventData]);
};
export default useCustomEvent;

We're also making sure that we follow the Rules of Hooks and specifying the dependencies of the useEffect Hook so if they update the effect will run.

The first place we'll use the Hook is on the Add To Cart button, like we did in the first article:

1
2
3
4
5
6
7
8
9
const AddToCart = ({productId}) => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  const [quantity, setQuantity] = useState(1)
  const [visible, setVisible] = useState(false)
  const {addToCart} = useContext(CartContext)
  const reactPlugin = useAppInsightsContext()
  useCustomEvent(reactPlugin, 'Added to Cart', quantity)
  // snip

But wait, we have a problem here, now every time the quantity state changes our Effect will run, not when you click the button (or some other controlled action). This isn't ideal since it's an input field, so instead, we need to think differently about how to trigger the Effect.

Adding More Hooks

To solve this we'll add more Hooks! In particular, we'll add the useState Hook to our custom one.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { useState, useEffect, useRef } from "react";

export default function useCustomEvent(reactPlugin, eventName, eventData) {
    const [data, setData] = useState(eventData);

    useEffect(() => {
        reactPlugin.trackEvent({ name: eventName }, data);
    }, [reactPlugin, data, eventName]);

    return setData;
}

We'll create some internal state, which I've called data, and initialise it with whatever we pass as the eventData. Now in our dependencies we'll stop using eventData and use data then return the setData state mutation function from our Hook. With this change we will update our usage in Add to Cart like so:

1
2
3
4
5
6
7
8
9
const AddToCart = ({productId}) => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  const [quantity, setQuantity] = useState(1)
  const [visible, setVisible] = useState(false)
  const {addToCart} = useContext(CartContext)
  const reactPlugin = useAppInsightsContext()
  const trackAddedToCart = useCustomEvent(reactPlugin, 'Added to Cart')
  // snip

We now have a function that is in the variable trackAddedToCart that can be used at any point in our component to trigger off the effect:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// snip
Moltin.addToCart(cartId, productId, quantity).then(() => {
    addToCart(quantity, cartId);
    setLoading(false);
    setQuantity(quantity);
    setVisible(true);
    toggleMessage();
    trackAddedToCart({ quantity, cartId, productId });
});
// snip

Here once the cart has successfully been updated we track the event with some data that we want.

Ignoring Unwanted Effect Runs

If you were to start watching your AppInsight logs now you'll see that you're receiving events for the interaction, but you're also receiving other tracking events from when the component first renders. That isn't ideal is it! Why does this happen? well, the Effect Hook is similar to componentDidUpdate but also componentDidMount, meaning that the Effect runs on the initial pass, which we may not want it to do, especially if the Effect is meant to be triggered by a certain action in our component.

Thankfully, there's a solution for this and that is to use the useRef Hook. We'll update our custom Hook to allow us to set whether we want the componentDidMount-equivalent life cycle to trigger the effect or not:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { useState, useEffect, useRef } from "react";

export default function useCustomEvent(
    reactPlugin,
    eventName,
    eventData,
    skipFirstRun = true
) {
    const [data, setData] = useState(eventData);
    const firstRun = useRef(skipFirstRun);

    useEffect(() => {
        if (firstRun.current) {
            firstRun.current = false;
            return;
        }
        reactPlugin.trackEvent({ name: eventName }, data);
    }, [reactPlugin, data, eventName]);

    return setData;
}

The argument, skipFirstRun, will be defaulted to true and we create a ref using that value. Then when the Effect runs we check if we are to skip the first run, we update the ref and return early from the function. This works because the ref mutation doesn't notify changes to the component and thus it won't re-render.

Conclusion

Throughout this post we've had a look at how to use Hooks with AppInsights to create a programmatic model that feels like how we would expect a React application to work.

We started by introducing Context so that we can resolve the React AppInsights plugin through the React component structure rather than treating it as an external dependency. Next, we created a custom Hook that allows us to track events through the Hook life cycle and learnt a bit about how the Hooks can be triggered and what to do to handle them in the smoothest way possible.

You'll find the sample I used in this post on GitHub with the custom Hook, Add to Cart component and a second usage on the Remove from Cart page.

At the time of writing the AppInsights React plugin doesn't provide a method trackEvent, so I patched it myself when initializing the plugin:

1
2
3
ReactPlugin.prototype.trackEvent = function(event, customProperties) {
    this._analyticsPlugin.trackEvent(event, customProperties);
};

Bonus Feature - Track Metrics via Hooks

The React plugin provides a HOC for tracking metrics such as interaction with a component, so I thought, why not look to see if we can do that with a Hook?

To do that I've created another custom Hook, useComponentTracking that simulates what the HOC was doing, but doesn't inject a DOM element, you need to attach it to the element(s) yourself. I've updated the Layout component to show how it would work too.


Published: 2019-11-19 11:47:19 +1100 +1100, Version: b5970d7