Know when a data layer array is populated asynchronously

By Simon Smithin JavaScript

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.

diagram showing an overview of a datalayer interaction

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 the detail 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 its this binding. In this situation it's important to ensure we call it with the original this, otherwise push will throw an error.

    Why do we use apply here, instead of the more well known call? Technically push can accept any amount of arguments, so we capture those using the spread operator on the item argument and pass it along. The same can be achieved with originalPush.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)

console output

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)

console output

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.

Comments