Google Analytics with React Router

Johnny Magrippis
Mar 1 · 11 min read

Even if you’re building a fun side-project with no “monetisation strategy”, you should gather analytics on it. The easiest way to do that remains Google Analytics but if you’re building a single page app, such as a Create React App with React Router, you’ll find the suggested copy & paste solution has a gap!

Image for post
Image for post
Photo by Alex Radelich on Unsplash

You’ll be tracking where the users land, but you won’t be tracking them navigating to different routes! If the blessing of client-side routing is blazing fast navigation, this is its curse: You aren’t really hitting a new page on the server, you are always served the same /index.html and remain there throughout your session.

React Router is informing your browser you made it to a new page, but it’s all pretend! A ruse for the sake of skipping a roundtrip to the server and keeping your app feeling fast and responsive. So even if your browser’s history thinks you went from /, to /about, to /contact it only every did one request to the server, that first one to /.

Which is why if you do only follow the basic setup guidelines for Google Analytics, you’ll only be tracking that initial request to /, which may lead you to think that all the other routes in your app are totally useless and nobody ever makes it there.

So, let me show you the easy way to add Google Analytics in a client-side React app, using a hook you’ll write yourself; no bespoke libraries to install!

If you follow the flow to set-up a new account / property for your new app in Google Analytics, you’ll eventually make it to a page that asks you to paste a snippet of code “immediately after the <head> tag on every page of your site”:

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', 'GA_MEASUREMENT_ID');
</script>

As mentioned above, if you have a Create React App with React Router, you only really have one page on your site! So it makes sense to add the above in your public/index.html, substituting GA_MEASUREMENT_ID with your tracking key:

And as mentioned above, that will work, but only to track where the users initially land.

Image for post
Image for post
Photo by Joshua Earle on Unsplash

But what if your App looks something like this:

Looks like besides the home / route, you have two more, /about and /contact. Presumably somewhere in the Header you have links the users can click to navigate there. So, how do you track that?

Eagle-eyed readers might notice the docs do have a little section called Single page applications, where they instruct us to call gtag again when “the site loads new page content dynamically rather than as full page loads”, in a way like this:

gtag('config', 'UA-1234567-89', {'page_path': '/new-page.html'});

But how do we know that “when”, how do we know when we’ve navigated to a new “client-side” route?

Luckily React Router nowadays gives you a hook to get its history object, and that history object has a method to attach a listener for when it changes! This is what we’re going to use in our own hook:

This might be a lot to parse, so let’s break it down section by section:

For hooks, even that is important! For React to know that something is a hook, it needs to be defined as a method that starts with use. If it doesn’t, it won’t know to run it with all the hook magic it needs to work.

Kinda like how in order for React to know something is a React Component, it needs to start with a capital letter. We import App, not import app, and similarly here, we need to import useTracking. t’s not a convention. We have to do it!

So, in useTracking.ts, we will be using something that has a side-effect! We will be listening to changes in route history, and we will be informing Google of those changes.

In the age of React hooks, you wrap those sorts of things in useEffect, so we’re bringing it in:

import { useEffect } from 'react'

useHistory is the hook that will get us the history object our Router in App.tsx will be using. We want to listen for its changes!

import { useHistory } from 'react-router-dom'

These examples are written in Typescript, which is made to stop us from running methods that do not exist.

Usually it figures things out “automatically” after analysing our source code. But how can Typescript know that snippet we’ve pasted inline in public/index.html adds a gtag method to the global? It doesn’t know all our source code is only meant to be imported by that public/index.html, after it’s injected that method.

So we have to declare what could be in the window ourselves, like so:

declare global {
interface Window {
gtag?: (
key: string,
trackingId: string,
config: { page_path: string }
) => void
}
}

Google uses a few names for this glorified apiKey; in the example above, I have it down as UA-YOUR1KEY-HERE, and you are of course supposed to replace it with your own, actual key / project id / GA_MEASUREMENT_ID.

In practice, it’s unlikely you’ll want to be calling this method with different trackingIds throughout your app. You’d usually want to use it once per app, with a single apiKey, so you may as well hardcode it in, or use process.env.GA_MEASUREMENT_ID , or whichever environment variable you make up, directly in the body of this method.

However, I do think accepting an argument makes it easier to publish this hook, if you’re so inclined! Or, arguably, more likely you’ll remember to update to the correct value when you paste it in your next app!

export const useTracking = (
trackingId: string | undefined = process.env.GA_MEASUREMENT_ID
) => {

As foretold, we’ll be using the useHistory hook to get the history object the Router of our App will be using. We’re only interested in one of its methods, the one that you may use to attach a callback to run there is a change in browser history. In effect, any time the route changes! That method is aptly named listen, and we might as well destructure it straight away:

const { listen } = useHistory()

We haven’t really seen what we’re setting up yet, but this line appears first so it may trip you up:

useEffect(() => {
const unlisten = listen((location) => {
Image for post
Image for post
Photo by Johnny McClung on Unsplash

This section is going to be quite technical so feel free to skip it / skim it and not worry too much about understanding it yet! You may always come back to it later.

Whenever you useEffect, you should consider whether that effect requires cleanup. If you are attaching event listeners or setting timeouts, the answer will virtually always be “yes”.

Chances are your app will work mostly as expected even if you don’t, but you’ll be opening the door to performance issues and bugs that are hard to debug.

In our case without cleanup, we would keep adding listeners that would keep sending page_path updates to google analytics, so “double / triple / n-where-n-equals-how-many-times-our-App-component-re-renders”-counting our page views.

Depending on how you’ve set up your App and where you actually use this hook, there is a chance it will never be re-run and dodge that bullet by chance. But consider if you were setting up a similar hook for an app like Instagram, supposed to run every time a user clicks one of the “heart” buttons which appear at the end of each of post in the timeline? That’s dozens of handlers each potentially re-added every time the heart goes from just an outline to filled with colour! Never mind turning valuable user data to rubbish, that’d be a noticeable UX performance hit.

In any case, if you’d rather not need to be dodging bullets, you need to set up the instruction to remove the event listener. Helpfully, history’s listen method returns another method, a method which removes the event listener you just set up! Which is why we wrote const unlisten = listen(...), we’ll keep that unlisten method and run it when we want to remove the event listener.

But when do we want to run it? Well, during the hook’s cleanup phase. The method you pass to useEffect can have a return value of the method you want to run during its cleanup. Which is why later on we return unlisten. This instructs React to unlisten() during the hook’s cleanup phase, which will remove the “route change event listener” we setup.

Whew, what a section! If you made it through, well done! If you didn’t make it through, well done regardless. This article is more about setting up Google Analytics using React hooks, which you can do without knowing the ins and outs of useEffect. So let’s move on… To more useEffect particulars!

So in the super technical section above we’ve set up an effect and its cleanup. You don’t need to know the details if you skipped it, as now we’ll be zooming in to what we’re doing with the listen method!

So how does listen work? Well, you call it with the handler you want, the method you want to run whenever there is a change in browser history. In essence, whenever the user navigates to a different route! So, in an App with React Router, whenever they click a Link component, or anything we’ve set-up to programatically history.push('/somewehere-else').

listen will call the given method with a location object as the first argument, and an action as the second argument. We won’t needing action in our hook, so think about it no further.

So we create an anonymous fat arrow method, which will only be using its first parameter, location.

listen((location) => {

We then check to see if a gtag method really exists in the window, and early out if not.

if (!window.gtag) return;

In practice, we know this will always be the case, since our inline script in public/index.html runs before this hook. But do we really know this? What if we implement the gtag injection somewhat differently? What if a conflicting script in the page has deleted that global method for whatever reason? What if my grandma had wheels?

Unlikely ifs aside, you can never be too safe with the global scope, so might as well do a sanity check.

Image for post
Image for post
Photo by Rob Schreckhise on Unsplash

Next up we have a more needed sanity check, as it guards against human error:

if (!trackingId) {
console.log(
"Tracking not enabled, as `trackingId` was not given and there is no `GA_MEASUREMENT_ID`."
)
return
}

I love chatty / helpful logging made to help developers figure out what went wrong. The React team is exemplary for that I find, so I figured to try it here!

So if we call this hook without a trackingId, and there is no process.env. GA_MEASUREMENT_ID to default to, we get this log to clue us in on why we still can’t see any page views other than / in our dashboard.

We then return to not even attempt to register the page_path update, as it won’t work without a trackingId. In case our trackingId is set but is not valid, for instance if we literally used the string 'UA-YOUR1KEY-HERE' pasted in from the example above, it’s Google’s script that will tell us there’s something fishy going on. We could type-guard against this ourselves, but I think that’d be too much work for virtually zero benefit.

Finally, we made it to where we signal Google to say “actually, we’re now on a new route”! It’s akin to how React Router tells the browser’s history to act as if we’re on a new route, only we’ve wrote this code ourselves!

window.gtag('config', trackingId, { page_path: location.pathname })

Well, we’ve pasted it in from the documentation mentioned above and tweaked it to use arguments… Still counts!

We end with the second argument to useEffect, its array of dependencies:

}, [trackingId, listen])

We’ve talked a lot about useEffect; probably too much! So for its dependency array, let’s just say we’re telling React it should only rerun this effect again if the trackingId useTracking takes as an argument, or the listen method from useHistory, change.

In our use-case trackingId and listen will never change, so it will only run once, for the first render, and never rerun. Even if it did, it’d be no problem since we’ve taken care to do proper cleanup in the section above.

And that’s it for the hook! If we mange to use it we’re done!

Our hook is ready, but since it calls useHistory from React Router, it can only run in a component or other hook that is inside a Router. The App we’ve defined above is not inside a Router. It’s where we first use a Router, by rendering <BrowserRouter>!

Image for post
Image for post
Photo by Javier García on Unsplash

So let’s refactor to make things a bit easier:

The big changes are that we’ve made our named export, export const App return JSX with no Providers. Where did our providers, ApolloProvider and BrowserRouter go?

They are now in our default export, which we’ve change from just being export default App, to be returning the App wrapped inside all of our Providers!

export default () => (
<ApolloProvider client={apolloClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</ApolloProvider>
)

I find this is helpful for testing, as we can now use our named export and render it inside our own custom providers: such as an in memory router for React Router, or the MockedProvider for Apollo.

But that is a subject for a different post! In our case, it’s not just helpful, but necessary, because now we’ll be able to use our hook inside App, since that const is now meant to be rendered inside BrowserRouter; instead of being responsible off rendering BrowserRouter in itself. A little switcharoo that allows us to…

All that remains is to add the import of our new hook, and call it in the body of the App!

import { useTracking } from './useTracking'
...
export const App = () => {
useTracking('UA-YOUR1KEY-HERE')

return (
<Container>
...
</Container>
)
}

And that’s it! The snippet in public/index.html will be tracking the very first, “full page load”, and our hook will be tracking every subsequent route change. Here is the full, final,App.tsx:

Image for post
Image for post
Photo by Nghia Le on Unsplash

If you’re feeling iffy about pasting in that old-school google code in public/index.html, and having two places to type in your GA_MEASUREMENT_ID, you could adapt useTracking to be the thing that loads the gtag script and makes that initial “full page load” call.

Do let me know if you’d like more detail on how that, more “programatic” solution would work; for now I figured the code discussed above is a more natural extension of what Google’s documentation is instructing you to do.

Speaking of letting me know, my “fun side-project” I’ve spent my last couple of weekends on and which triggered all of this is Emoji of the X.

It runs a scheduled task which writes to a Postgres database, as well as GraphQL server for it on Heroku, with a React frontend on Netlify. Styled Components and React Spring for the css and animations. And it now runs Google Analytics to see if anybody is actually visiting!

Let me know if you’d enjoy an article on any of the tech above!

JavaScript In Plain English

New JavaScript + Web Development articles every day.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store