sudolabs logo

9. 1. 2024

5 min read

Implementing GA4 in your React project

In this guide, we’ll walk you through a more advanced GA4 implementation to your React.js project that's both easily extendable and testable. Additionally, we'll highlight common obstacles faced during this integration process and provide straightforward solutions. Let’s dive in.

Kristián Müller

Our exploration centers around a comprehensive solution that involves the creation of a versatile provider capable of forking instances. Each instance not only manages its own data storage but also facilitates streamlined parsing of essential information, like lists of items, into the revered dataLayer. As we navigate through this solution, we'll uncover practical implementations, insightful transformations, and an array of techniques that empower React developers to conquer the complexities of data management.

Problem

We should be able to store data from the parent component independently from the child component. This means that if we have a child component as a widget and we need to execute an event to GA4, it should be able to read data from its parent, for example: the list of products, page area, etc. We should also consider the fact that widgets can be used in multiple places at once, so they should be able to access all necessary data from the parent component directly themselves.

Solution

  • Creating a provider that we can fork, respectively create a new instance of it in the React tree {if needed}.

  • Every instance should be able to store data

  • Instances can hold data like the list of items that we need to parse into the dataLayer

Visual representation of the described solution above:

Higher Order Component for shared properties

Since there may be a necessity for shared properties like area, page_area, etc. The best way to handle this is to create a Higher Order Component {HOC} which we can use on specific routes/pages .{for example /shop}

This way we can store properties in the provider for event execution calls in the React tree below the wrapped component.

After some event execution call happens, its transformer can then easily read those property values from the provider tree with the helper function findPropertyInTree.

Example Higher Order Component:

import { ComponentType, VoidFunctionComponent } from 'react'
import GoogleAnalyticsProvider, {
useGoogleAnalytics,
} from '~/GoogleAnalyticsProvider'
interface ProviderDataProps {
area: string
page_area: string
item_list_name: string
...OTHER_PROVIDER_DATA_PROPS
}
export const withGA4Provider =
(providerDataProps: Partial<ProviderDataProps>) =>
(Component: ComponentType<object> | VoidFunctionComponent<object>) => {
const WrappedComponent = <T extends object>(props: T) => {
const GA4 = useGoogleAnalytics()
return (
<GoogleAnalyticsProvider parent={GA4} data={providerDataProps}>
<Component {...props} />
</GoogleAnalyticsProvider>
)
}
return WrappedComponent
}

Example usage:

export const Shop = withGA4Provider({
area: '...',
page_area: '...',
item_list_name: '...',
...OTHER_PROVIDER_DATA_PROPS,
})(ShopPageComponent)

executeDataToDataLayer function

After creating GoogleAnalyticsProvider we need to define a common method that will be used for calling events.

The method should allow us to easily pass event name, event-specific raw data, and a boolean flag on whether to include aggregated data that we are storing in the provider. {these data may include for example: items that the user has viewed on some page on our website → we should be aggregating these data with some observer}

After the function gets called it should call the correct transformation depending on the event prop that is being passed, to transform the data that are passed as well.

Lastly, the method should either have conditions in itself or call another method where the event to dataLayer pushing strategy is implemented.

Example executeDataToDataLayer function:

const executeDataToDataLayer = useCallback(
({
event,
overrideData = {},
includeStoredData = true,
}: ExecuteDataToDataLayerProps<typeof overrideData>) => {
// If google analytics are not loaded yet, it should not process this method.
if (
!window?.dataLayer ||
process.env.NEXT_PUBLIC_NODE_ENV !== 'production'
) {
return
}
// Context data represent data of current provider overrided by overrideData
const contextData = {
...(includeStoredData && storedData.current),
...overrideData,
}
if (GA4_ECOMMERCE_EVENTS[event]) {
if (isPageViewLoading) {
dataLayerBuffer.current.push({ ecommerce: null })
} else {
window.dataLayer.push({ ecommerce: null })
}
}
// In SPA eccomerce events require _clear parameter
const isClearEvent = GA4_ECOMMERCE_EVENTS[event]
try {
// Transform passed data for the event that is being called
const transformedInputData = transformationInputDataToGA4DataLayer({
event,
contextData,
parent,
...OTHER_COMMON_PROPS // For example: pathname, user, userType, etc.
})
if (!transformedInputData) {
return
}
// Call function with the dataLayer pushing strategy
// -> Takes care of the order how the events are being pushed to dataLayer
handleDataLayerPushMethod({ event, isClearEvent, transformedInputData })
} catch (err) {
const errorMessage = err instanceof Error ? err.message : err
const fullError = new Error(
JSON.stringify({
error: errorMessage,
event,
contextData,
})
)
Sentry.captureException(fullError)
}
},
[
dataLayerBuffer,
handleDataLayerPushMethod,
isPageViewLoading,
parent,
...OTHER_COMMON_PROPS,
]
)

Transformations

What are transformations?

  • In simple terms, transformators are functions which take raw data as an input

  • These functions transform then the passed data and return them in a form they must be pushed into the dataLayer

Why transformations?

  • Can be tested as a unit

  • Are easy to extend

  • Offer a great error-handling approach

Writing transformation

Helper function for accessing properties from parent providers:

export const findPropertyInTree = <T = any>({
keyName,
contextData,
parent,
}: FindPropertyInTreeProps): T => {
const prop = get(contextData, keyName)
// Check type of prop instead of value (0 can be valid value)
if (prop !== undefined) {
return prop
}
if (!parent) {
return undefined
}
return findPropertyInTree({
keyName,
contextData: parent.data.current,
parent: parent.parent,
})
}

Example transformation for click event:

import { GA4_PARAMS, TransformationProps } from '~/types'
import { findPropertyInTree } from '~/utils'
// Interface of context data passed to the transformation
interface ClickTransformationContextData {
text: string
url?: string
name?: string
}
type ClickTransformationProps = TransformationProps<
ClickTransformationContextData, // contextData type
'contextData' | 'parent' // Parameters we want to pick from general provider type that may be extended with some others
>;
export const ExampleTransformation = ({
contextData,
parent,
}: ExampleTransformationProps) => {
const { text, url = '', name = '' } = contextData
return {
exampleParamZero: findPropertyInTree({
keyName: GA4_PARAMS.PAGE_AREA, // For parameter name, we can use a common declared constant that holds all of our parameters {e.g. GA4_PARAMS}
contextData,
parent,
}),
text,
url,
name,
}
}

Testing transformation

Example test for click event transformation:

import { ClickTransformation } from '~/transformations'
describe('GA4 - click', () => {
it('should transform correctly', () => {
const result = ClickTransformation({
contextData: {
text: 'text',
url: 'url',
name: 'name',
},
})
expect(result).toMatchObject({
text: 'text',
url: 'url',
name: 'name',
})
})
})

Tracking page view event on Single Page Applications

How to track page view events on Single Page Application {SPA}?

  • The best approach to go when tracking page_view event on an SPA is to create a wrapper component that should take just children as props if there isn’t a necessity for any other props to be passed.

  • This component should be placed on the outermost level of the component tree in _app.tsx .

  • By going with this approach we ensure that the page_view event is being called on every page. But what is more, we can also add a route restriction mapping so we ensure that the event is not being executed on some specific pages.

Example of the wrapper component:

import React, { useEffect } from 'react'
interface Props {
children: React.ReactNode
}
export const PageViewTracker: React.FC<Props> = ({ children }) => {
{/* ... */}
useEffect(() => {
if (
YOUR_CONDITIONS
) {
PAGE_VIEW_EVENT_CALL
}
}, [YOUR_DEPENDENCIES])
{/* ... */}
return <>{children}</>
}

Accessing title from window object

How can we access the page title?

  • When sticking with the approach described above there shouldn’t be any problem with accessing the title directly from the window object, since the window object is available in useEffect hook.

Event order

Google Analytics has prescribed the order in which events must be pushed to dataLayer, some events may vary depending on the specific project.

The following order applies for each SPA:

  1. consent_default ⇒ region-specific default cookie consent settings

  2. consent_update ⇒ user’s consent settings

  3. page_view

These events should be fired on each page in this order.

useEffect hook problem

Since many event calls must be called in useEffect hook and the fact that Google Analytics has a prescribed order in which some of the events must be pushed into the dataLayer, we must understand how React.js handles their execution:

During the execution of the parent component, if a child component needs to be rendered, the parent component’s render is halted. The child component starts rendering and after its (child component’s) rendering is complete, its (child component’s) set of useEffect hooks are processed. After it’s done, the parent component’s render is resumed.

Refer to below for a visual representation of the explanation above.

Sample code:

import React, { useEffect } from 'react'
const Child: React.FC = () => {
console.debug('Child.render: start')
useEffect(() => {
console.debug('Child.useEffect')
}, [])
console.debug('Child.render: end')
return <p>Child</p>
}
const Parent: React.FC = () => {
console.debug('Parent.render: start')
useEffect(() => {
console.debug('Parent.useEffect')
}, [])
console.debug('Parent.render: end')
return <Child />
}
export default Parent

Console output:

🐞 Parent.render: start
🐞 Parent.render: end
🐞 Child.render: start
🐞 Child.render: end
🐞 Child.useEffect
🐞 Parent.useEffect

In navigating our project's expansion, integrating Google Analytics into our website emerges as a pivotal step. While our highlighted Google Analytics implementation in React.js provides a solid foundation, it's essential to recognize that it may not be the exclusive solution for every project. The beauty of this integration lies in its versatility – effortlessly extendable, easily testable, and offering the flexibility to distribute event execution seamlessly throughout your codebase.

This tool not only empowers us to make data-driven decisions by providing insights into user behavior and engagement but also ensures that we stay agile and responsive to evolving trends with its real-time analytics capabilities. As we delve into this integration, it becomes clear that it's not merely about accommodating growth; it signifies a commitment to leveraging actionable data for the continued success of our project.

Share

Let's start a partnership together.

Let's talk

Our basecamp

700 N San Vicente Blvd, Los Angeles, CA 90069

Follow us


© 2023 Sudolabs

Privacy policy
Footer Logo

We use cookies to optimize your website experience. Do you consent to these cookies and processing of personal data ?