Know when a data layer array is populated asynchronously
6 min read
Sometimes it can be useful to know when an item has been added to an array at a later point in time, i.e asynchronously. An example of this might be when data items are pushed into a data layer and our application needs to react to it.
If you're not familiar with the concept of data layers, then this post on the Segment blog is a good start. Essentially it's an array that allows data to be pushed into it, and this will be sent to the analytics service.
Use case
Imagine a scenario where our application needs to know when a certain event was added to the data layer and do something with that information when it happens. Tools like Segment and Google Tag Manager focus on reacting to events within their respective applications, but sometimes our application might want to be informed about it as well.
Scenario A: Using events
In this scenario your application has access to the array before any events or data have been pushed into it. In our data layer example this might be before a library like Google Tag Manager has been initialised and before any events from our application are fired.
The simplest approach is to intercept any calls to the push
method on the
array, trigger an event with the payload attached and then push the data into
the original array.
Let's look at the solution and then step through it:
const data: unknown[] = []
class EventEmitter extends EventTarget {}
const emitter = new EventEmitter()
{
const originalPush = data.push
data.push = function (...item) {
const event = new CustomEvent('onDataItemAdded', {detail: item})
emitter.dispatchEvent(event)
return originalPush.apply(this, item)
}
}
emitter.addEventListener('onDataItemAdded', (user: CustomEvent<unknown>) => {
console.log(user.detail[0])
})
To start with, the data array that will have items pushed into it. If you've
used Google Tag Manager then you'll know this as the dataLayer
array:
const data: unknown[] = []
Next, we want a way to emit and listen to events. If you use Node
then you have access to the EventEmitter
class that can be extended to
create an emitter. This allows use of addListener
and emit
and it's a really
convenient way of creating an event bus:
// node example
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {}
const myEmitter = new MyEmitter()
emitter.emit('someEvent')
emitter.addListener('someEvent', () => {
// handle event
})
Thankfully we have an almost identical API that can be used in the browser,
EventTarget
.
The main difference is here we use addEventListener
and dispatchEvent
instead:
// browser example
class EventEmitter extends EventTarget {}
const emitter = new EventEmitter()
emitter.dispatchEvent('someEvent')
emitter.addEventListener('someEvent', () => {
// handle event
})
With the emitter to hand we can now intercept any calls to the push
method on
the data
array, dispatch an event and then call the original push
method so
that the items are correctly added:
{
const originalPush = data.push
data.push = function (...item) {
const event = new CustomEvent('onDataItemAdded', {detail: item})
emitter.dispatchEvent(event)
return originalPush.apply(this, item)
}
}
A few things to note here:
- Using curly braces we can create a block scope to keep the reference to the
originalPush
variable out of the parent scope. Just a good practice in general. - If we want to pass some
data
along with the event then it's necessary to make use of
CustomEvent
and attach it via thedetail
property. In this case we want to broadcast what was just pushed into the array. - Because we stored a reference of
data.push
in a variable it will lose itsthis
binding. In this situation it's important to ensure we call it with the originalthis
, otherwisepush
will throw an error.
Why do we useapply
here, instead of the more well knowncall
? Technicallypush
can accept any amount of arguments, so we capture those using the spread operator on theitem
argument and pass it along. The same can be achieved withoriginalPush.call(this, ...item)
so it's up to you which one you prefer.
Listening in
With that in place, the final part is to listen to our new event whenever it is triggered:
emitter.addEventListener('onDataItemAdded', (item: CustomEvent<unknown>) => {
console.log('onDataItemAdded', item.detail[0])
})
We can test this now and verify the console output:
// Some time later in the application...
setTimeout(() => {
data.push({name: 'Simon'})
data.push({name: 'John'})
}, 1000)
setTimeout(() => {
data.push({name: 'Jill'})
}, 3000)
Try it for yourself below:
Scenario B: Using promises
This time we do not have access to the array beforehand, so items could have already
been pushed into it and it's too late to override push
. What could make sense
as an approach here is to search the array until our item is found, and resolve
a promise with the result once it is.
To get started, we'll need a way to keep checking the array. For this I've
written a retry
utility that will keep trying to resolve a promise until a
pre-determined set of retries is reached. It will either resolve with the item
we want, or give up and reject:
export const retry = <T>(
// The only required argument is a function that
// will return a resolved promise when it's
// successful, otherwise return a rejected one
funcToRetry: () => Promise<T>,
{
retries = 5,
delay = 1000,
// `backOff` is how many ms to add to delay on
// each try, this is used to gradually increase
// how long it waits before retrying again. A good use
// case here might be when you are trying to hit
// an API endpoint each time
backOff = 0,
}: {
retries?: number
delay?: number
backOff?: number
} = {},
) => {
// a quick and easy delay helper
const waitFor = (delay: number) =>
new Promise((resolve) => setTimeout(resolve, delay))
// each time the funcToRetry fails this
// function will check if there are retries left, and
// if so start the process again
const handleError = (): Promise<T> => {
retries -= 1
if (retries === 0) {
throw new Error('exceeded retries')
}
// this is just for debugging...
console.log(
`retry.ts retries remaining: ${retries}.\nnext attempt in ${delay}ms`,
)
return waitFor(delay).then(() =>
retry(funcToRetry, {delay: delay + backOff, retries, backOff}),
)
}
// ensure we try the `funcToRetry` at least once
return funcToRetry().catch(handleError)
}
This is a good, general purpose utility to have around. You can grab it from this gist along with some tests if you want to try it.
Using the retry utility
All that is required now is to write a function that will search the array for the item that we need:
type DataItem = {
name: string
}
const findUserByName = (
value: string,
array: DataItem[],
): Promise<DataItem> => {
return new Promise((resolve, reject) => {
const item = array.find((item) => item.name === value)
if (item) {
resolve(item)
} else {
reject('error')
}
})
}
In this example it's a simple use of find
. Depending on your use case you may
want/need to use different array methods or something more explicit like for of
. The idea is the same though, we either resolve with the item or reject with
any value.
We can try it out by adding an item after a short timeout:
const data: DataItem[] = [{name: 'John'}, {name: 'Jill'}]
retry(() => findUserByName('Simon', data))
.then((val) => {
console.log(val)
})
.catch(console.error)
setTimeout(() => {
data.push({name: 'Simon'})
}, 3000)
And I've created another example to try:
And that's it
Two different approaches to finding an item added to a data layer.
A third way I'd like to explore is using a Proxy which I'll cover in a separate article.