Integrate Google Analytics with React Router v4

Or how to accomplish this without polluting your entire application.

If you have to "just integrate that Google Analytics snippet" (can you hear your project manager/team lead/boss / ... already?) into your app and your application is a SPA, you know that it's not that simple.

Luckily, it's not that difficult either 😎

Recommended approaches

The most mature project for integrating Google Analytics into a React project is React-GA. Surely, it is not overly complicated to write an abstraction layer on top of the existing GA API, but why re-invent the wheel when this project is already battle-tested?

React Router v4 changed quite a lot of things since it is a total rewrite. By looking at the React GA's wiki page, we see there are several recommended approaches:

withPageView (HOC)

Initial idea. Use this higher-order component to wrap every page (container component). Every time our container components mount, the page view is tracked. Simple!
There are several drawbacks to this approach, however:

  • wrapping every container component is repetitive
  • separation of concerns (all of a sudden every page is wrapped in this HOC)
  • just does not feel very "React way"

withTracker (HOC)

Another higher-order component, this time to wrap an entire app component or Route component(s) to track page views. Ok, this seems better.
There are again several drawbacks to this approach, however:

  • it requires additional dependency history.
  • since the listener is listening for history changes, this will not fire on the initial page load
  • the listener is hooked into the render cycle which means that the wrapped component - even when it returns false from shouldComponentUpdate - will trigger the GA call

As Redux Middleware

This is better. It catches a router react dispatch of action type @@router/LOCATION_CHANGE and hits GA.
Very clean and succinct. However, not possible to use if you are not using Redux or not letting React Router to dispatch actions.

If none of these approaches seem ideal (like in my case), read on.

Route Component (IMHO best approach)

Let's work backward to see how we want to wire this up. I often find myself working my way "backward" like this when thinking in React.

How to consume it

index.js is the app entry point. Pretty standard React stuff here.

// index.js
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter, Switch, Route } from 'react-router-dom'

import UnauthorizedLayout from 'layouts/UnauthorizedLayout'
import ErrorLayout from 'layouts/ErrorLayout'
import AuthorizedLayout from 'layouts/AuthorizedLayout'
import AuthorizedRoute from 'custom-routes/AuthorizedRoute'
import GA from 'utils/GoogleAnalytics'

import './scss/app.scss'

class App extends Component {
  render () {
    return (
        <BrowserRouter>
          { GA.init() && <GA.RouteTracker /> }
          <Switch>
            <Route path='/auth' component={UnauthorizedLayout} />
            <Route path='/error' component={ErrorLayout} />
            <AuthorizedRoute path='/' component={AuthorizedLayout} />
          </Switch>
        </BrowserRouter>
    )
  }
}

ReactDOM.render(<App />, document.getElementById('root'))

Since everything in React Router v4 is a component, our app's root element is BrowserRouter component. There is also usually at least one Switch component that will route to an appropriate route. As an added note, various layouts existence is an abstraction for nested routes with added, well... layout 😉 This also gives me an idea for the next blog post!

We are interested in { GA.init() && <GA.RouteTracker /> }
init will check if the current environment will be using Google Analytics. It will return a boolean and RouteTracker component will be conditionally rendered. Since it is not wrapped in a Switch component, it will be rendered every time depending on what init returns.

Why on Earth would we want to render our GA tracker at all? Well, we want to be able to fire ReactGA's set and pageView methods. This component will always render null since DOM wise we don't need it to do anything. Since it is a React component, we will be able to tap into the component's lifecycle hooks and hopefully have enough context to know when we want to tell GA that the route has changed.
Everything's a component, remember? 😎

Implementation

Now that we defined how we want to consume this, let's get into the implementation.

// utils/GoogleAnalytics.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ReactGA from 'react-ga'
import { Route } from 'react-router-dom'

class GoogleAnalytics extends Component {
  componentDidMount () {
    this.logPageChange(
      this.props.location.pathname,
      this.props.location.search
    )
  }

  componentDidUpdate ({ location: prevLocation }) {
    const { location: { pathname, search } } = this.props
    const isDifferentPathname = pathname !== prevLocation.pathname
    const isDifferentSearch = search !== prevLocation.search

    if (isDifferentPathname || isDifferentSearch) {
      this.logPageChange(pathname, search)
    }
  }

  logPageChange (pathname, search = '') {
    const page = pathname + search
    const { location } = window
    ReactGA.set({
      page,
      location: `${location.origin}${page}`,
      ...this.props.options
    })
    ReactGA.pageview(page)
  }

  render () {
    return null
  }
}

GoogleAnalytics.propTypes = {
  location: PropTypes.shape({
    pathname: PropTypes.string,
    search: PropTypes.string
  }).isRequired,
  options: PropTypes.object
}

const RouteTracker = () =>
  <Route component={GoogleAnalytics} />

const init = (options = {}) => {
  const env = window._env_ || {}
  const isGAEnabled = !!env.REACT_APP_GA_TRACKING_ID

  if (isGAEnabled) {
    ReactGA.initialize(
      env.REACT_APP_GA_TRACKING_ID, {
        debug: env.REACT_APP_GA_DEBUG === 'true',
        ...options
      }
    )
  }

  return isGAEnabled
}

export default {
  GoogleAnalytics,
  RouteTracker,
  init
}

init is fairly easy. We execute ReactGA.initialize if the current ENV is configured to use GA. Why window._env_ and not process.env? Another blog post idea!
EDIT: If you are more interested in this, here's the post Configure Create React App to consume ENV variables during run-time

GoogleAnalytics is our component in charge of using ReactGA when the route changes. How do you ask? Well, we know that our component will be nested inside BrowserRouter (or any other React Router v4 router). If the idea for this component is to be "rendered" on every page change, then we create a route that matches everything. That is exactly what RouteTracker does.

Slowly peeling the layers, we finally get to the GoogleAnalytics component! I mentioned that we will take advantage of the React component's lifecycle hooks.
So componentDidMount will conveniently fire our logPageChange method when it mounts or - more specifically - on the initial page load.
Another hook we tap into is componentDidUpdate. Each time this component updates (for whatever reason, but most likely for a route change), we compare previous props (provided to us through a parameter) with current props. If the pathname and/or query string is different, we log the page change again.
The logPageChange method itself uses React GA's method set and pageView to actually trigger the page change on the Google Analytics side. We also include location field with window.location.origin included due to ReactGA's issue.

In and out. Ciao!