Collector
Collector is a collection of React components that facilitates user-interaction tracking for complex interfaces with a predictable event structure.
Table of contents
TLDR
import React from 'react';
import {
TrackingRoot,
TrackingView,
TrackingZone,
useClickTrigger
} from '@sumup/collector';
function Button({ onClick, 'tracking-label': trackingId, children }) {
const dispatch = useClickTrigger();
let handler = onClick;
if (trackingId) {
handler = e => {
dispatch({ label: trackingId, component: 'button' });
onClick && onClick(e);
};
}
return <button onClick={handler}>{children}</button>;
}
function App() {
return (
<TrackingRoot
name="my-app"
onDispatch={event => {
console.log(event);
}}
>
<TrackingView name="page">
<TrackingZone name="zone-a">
<Button tracking-label="show-content-a">Click me</Button>
</TrackingZone>
<TrackingZone name="zone-b">
<Button tracking-label="show-content-b">Click me</Button>
</TrackingZone>
</TrackingView>
</TrackingRoot>
);
}
Motivation
The larger web applications grows, the harder it is to provide predictable and traceable tracking structures. Consider our usual analytics event dispatching:
import React, { useContext } from 'react';
import { TrackingContext } from 'your-tracking';
function Button({ onClick, label, category, value, dispatch, children }) {
let handler = onClick;
if (dispatch) {
handler = e => {
dispatch({ label, category, value });
onClick && onClick(e);
};
}
return <button onClick={handler}>{children}</button>;
}
function AccountBalance() {
return (
<Button
type="submit"
label="show-value"
category="balance"
dispatch={dispatch}
>
Click me
</Button>
);
}
function AccountPage() {
return (
...,
<Balance />
);
}
function Balance() {
return (
...,
<ShowBalance />
);
}
function App() {
return (
<TrackingContext.Provider
value={{
dispatch: e => {
window.dataLayer.push(e);
}
}}
>
<AccountPage />
</TrackingContext.Provider>
);
}
Now, what happens if we need to define the event category
somewhere else in the tree? For this small example might not sound like much:
function AccountBalance({ category }) {
const { dispatch } = useContext(TrackingContext);
return (
<Button
type="submit"
label="show-value"
category={category}
dispatch={dispatch}
>
Click me
</Button>
);
}
But over time this can tightly couple our component implementation with the analytics usage. Ideally the AccountBalance
component shouldn't have to worry about this sort of domain.
What about leveraging our already existing TrackingContext
?
function AccountBalance() {
const { dispatch, category } = useContext(TrackingContext);
return (
<Button
type="submit"
label="show-value"
category={category}
dispatch={dispatch}
>
Click me
</Button>
);
}
But having a context usage also implies that you eventually need to set the value somewhere in the tree:
function AccountBalance() {
const { dispatch, category } = useContext(TrackingContext);
return (
<Button
type="submit"
label="show-value"
category={category}
dispatch={dispatch}
>
Click me
</Button>
);
}
function Balance() {
const { dispatch, setValue } = useContext(TrackingContext);
useEffect(() => {
setValue({ category: 'account-balance' });
}, []);
return (
...,
<ShowBalance />
);
}
function App() {
const [trackingData, setTrackingData] = useState({});
return (
<TrackingContext.Provider
value={{
...trackingData,
dispatch: e => {
window.dataLayer.push(e);
},
setValue: value => setTrackingData({ ...trackingData, ...value })
}}
>
<AccountPage />
</TrackingContext.Provider>
);
}
But again, over time this can tightly couple our component implementation with the analytics usage, and the more fields you need to overwrite, the harder it is to reason about the current state of the context, and that's where Collector can help you!
Collector was built to track user-interactions with high granularity. Using an agnostic event schema you can serve different tracking purposes with it. Consider the same example using Collector:
import React from 'react';
import {
TrackingRoot,
TrackingView,
TrackingZone,
useClickTrigger
} from '@sumup/collector';
function Button({ onClick, 'tracking-label': trackingId, children }) {
const dispatch = useClickTrigger();
let handler = onClick;
if (trackingId) {
handler = e => {
dispatch({ label: trackingId, component: 'button' });
onClick && onClick(e);
};
}
return <button onClick={handler}>{children}</button>;
}
function AccountBalance() {
return (
<Button type="submit" tracking-label="show-balance">
Click me
</Button>
);
}
function AccountPage() {
return (
<TrackingView name="account">
...,
<Balance />
</TrackingView>
);
}
function Balance() {
return (
<TrackingZone name="balance">
...,
<ShowBalance />
</TrackingZone>
);
}
function toAnalyticsEvent({ view, zone, label, action }) {
return {
category: `${view}-${zone}`,
label: label,
action
};
}
function App() {
return (
<TrackingRoot
name="my-app"
onDispatch={event => {
window.dataLayer.push(toAnalyticsEvent(event));
}}
>
<AccountPage />
</TrackingRoot>
);
}
For more information about the event schema and component structure, please refer to the Usage section.
Installing
NPM
npm install @sumup/collector
yarn
yarn add @sumup/collector
Usage
Schema
Collector's philosophy is to structure your events based on your UI hierarchy. When dispatching events this way, it's easier to reason about the event payload. Based on this image we can start discussing about the event schema:
In order to support the app/view/zone hierarchy, the event schema is defined by the following keys:
interface Event {
app: string;
view: string;
zone?: string;
component?: 'button' | 'link';
label?: string;
event: 'click' | 'view' | 'load' | 'page-view' | 'submit' | 'browser-back';
customParameters?: {
[key: string]: any;
};
timestamp: number;
}
The directives (Root = app
, View = view
and Zone = zone
) are responsible for defining their respective attributes for the data structure. Whenever you dispatch an event, these values will be retrieved based on the component hierarchy, for example:
<TrackingRoot name="my-app" onDispatch={console.log}>
<TrackingView name="account">
...
<TrackingZone name="change-account-form">
...
</TrackingZone>
</TrackingView>
<TrackingRoot>
Would yield the following structure: { app: 'my-app', view: 'account', zone: 'change-account-form' }
.
You can also overwrite Zone
for complex trees:
<TrackingRoot name="my-app" onDispatch={console.log}>
<TrackingView name="account">
...
<TrackingZone name="change-account-form">
...
<TrackingZone name="validate-account-digit">
...
</TrackingZone>
</TrackingZone>
</TrackingView>
<TrackingRoot>
Would yield the following structure: { app: 'my-app', view: 'account', zone: 'validate-account-digit' }
. While it may not sound like much, it is really useful for larger applications.
TrackingRoot
The TrackingRoot is responsible for storing the app
value and the dispatch
function. It is recommended to have only one TrackingRoot per application.
import React from 'react';
import { TrackingRoot } from '@sumup/collector';
function App() {
const handleDispatch = React.useCallback((event) => {
window.dataLayer.push(event)
}, []);
return (
<TrackingRoot name="app" onDispatch={handleDispatch}>
...
<TrackingRoot>
);
}
To avoid unnecessary renders, we recommend providing onDispatch
as a memoized function.
TrackingView
The TrackingView is responsible for storing the view
value. It is recommended to have one TrackingView per "page".
import React from 'react';
import { TrackingView } from '@sumup/collector';
function App() {
return (
...
<TrackingView name="account">
...
<TrackingView>
);
}
TrackingZone
The TrackingZone is responsible for storing the zone
value. Zones are usually a representation of a feature/organism in your application such as a form.
import React from 'react';
import { TrackingZone } from '@sumup/collector';
function App() {
return (
...
<TrackingZone name="change-account-form">
...
<TrackingZone>
);
}
Dispatching events
Here are a list of supported events you can dispatch using pre-defined hooks:
- click
- pageView
- view (to be implemented)
- load (to be implemented)
- submit (to be implemented)
- browserBack (to be implemented)
Click
useClickTrigger
provides you a dispatch function for any kind of click event.
The dispatch function accepts the following interface:
interface Options {
component?: string;
label?: string;
customParameters?: {
[key: string]: any
};
}
import React from 'react';
import { useClickTrigger } from '@sumup/collector';
function Button({ onClick, 'tracking-label': label, children }) {
const dispatch = useClickTrigger();
let handler = onClick;
if (label) {
handler = e => {
dispatch({ label, component: 'button' });
onClick && onClick(e);
};
}
return <button onClick={handler}>{children}</button>;
}
PageView
What can be considered a page view?
- Page load
- Route changes in SPAs
- New "context" over the screen being displayed, such as modals.
The pageView
event will be dispatched with:
interface Event {
app: string;
view: string;
event: 'page-view';
timestamp: number;
}
Where to place the page view hook in your application
In order to have a meaningful page view event, we recommend integrating the available hooks for page view after declaring the TrackingRoot in your application.
You don't need to declare it after the TrackingView since any TrackingView
component will overwrite the context value.
Available hooks
usePageViewTrigger()
lets you dispatch a page view event.
import React from 'react';
import {
TrackingRoot,
TrackingView,
usePageViewTrigger
} from '@sumup/collector';
interface Props {
children: React.ReactNode;
location: string;
}
function PageView({ location, children }: Props) {
const dispatchPageView = usePageViewTrigger();
useEffect(() => {
dispatchPageView();
}, [location]);
return children;
}
usePageActiveTrigger
automatically dispatchs an event whenever the tab becomes inactive and then active again (via Visibility change). This is meant to be used whenever you want to track if people are changing tabs.
Keep in mind only one "pageActive" trigger is required since it's a document event listener.
import React from 'react';
import { usePageActiveTrigger } from '@sumup/collector';
interface Props {
children: React.ReactNode;
location: string;
}
function PageActive({ location, children }: Props) {
usePageActiveTrigger();
return children;
}
Code of conduct (CoC)
We want to foster an inclusive and friendly community around our Open Source efforts. Like all SumUp Open Source projects, this project follows the Contributor Covenant Code of Conduct. Please, read it and follow it.
If you feel another member of the community violated our CoC or you are experiencing problems participating in our community because of another individual's behavior, please get in touch with our maintainers. We will enforce the CoC.
Maintainers
About SumUp
SumUp is a mobile-point of sale provider. It is our mission to make easy and fast card payments a reality across the entire world. You can pay with SumUp in more than 30 countries, already. Our engineers work in Berlin, Cologne, Sofia, and Sāo Paulo. They write code in JavaScript, Swift, Ruby, Go, Java, Erlang, Elixir, and more.
Want to come work with us? Head to our careers page to find out more.