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 inputThese 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 transformationinterface 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:
consent_default
⇒ region-specific default cookie consent settingsconsent_update
⇒ user’s consent settingspage_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.
You might
also like