Illustration of the word 'Events'

Event Creation and Handling Techniques in TypeScript

TypeScript


A common problem you’ll run into when writing software is communication between components. Something happens in some component over here and I want to notify some component over there. Furthermore, I want to reduce coupling between components to increase maintainability. We can solve this problem with events.

Events provide a channel of communication between different parts of an application. Event emitters act as broadcasters, emitting events at specified points. Event consumers listen for those events and do something in response. Emitters don’t need to know ahead of time what will consume, or handle its events. This increases flexibility and decoupling.

We’ll look at some techniques for creating events and event handlers in JavaScript. I’m going to use TypeScript because it reveals some information about the types we’ll use. But these techniques apply to vanilla JavaScript as well.

Event Property Handlers

A simple technique for event creation and handling is the event property handler. Event property handlers allow consumers to define a method called during an event.

For example, let’s say we have a class Timer, which performs some basic timing functions. We want to register a handler that executes when the timer completes. First, we define a property signature, onComplete on the Timer that returns a callback. This will be an optional property.

Next, we’ll define a start method that begins a timeout. After the timeout completes, we fire our onComplete event. We check to see if the handler has a definition and if it does, we call it.

// Timer.ts
export default class Timer {
  public onComplete?: () => void

  public start(): void {
    setTimeout(() => {
      if (!this.onComplete) return
      this.onComplete()
    }, 7000)
  }
}

Now, in some other part of our application, we can instantiate the Timer and register a handler for the complete event. We do this by assigning a handler function to the onComplete property of the Timer instance.

// consumer.ts
import Timer from './Timer'

const t = new Timer()
t.onComplete = () => {
  console.log('timer completed event')
  // do some stuff
}
t.start()

Handler Limitations

Event property handlers are a simple way to create and handle events, but it does have a caveat. Attempting to define additional handlers will overwrite existing handlers. For example, in the following example, the second handler overwrites the first.

// consumer.ts
t.onComplete = () => {
  console.log('first handler definition')
  // this won't execute
}
t.onComplete = () => {
  console.log('second handler definition')
  // this will execute
}

Passing Event Data

To expose some data in the event handler, adjust the property signature with the expected argument type. Then when calling the method, pass in the data. In this example, the event callback exposes the time of the event firing. The consumer can use or ignore it.

// Timer.ts
export default class Timer {
  public onComplete?: (time: number) => void

  public start(): void {
    setTimeout(() => {
      if (!this.onComplete) return
      this.onComplete(Date.now())
    }, 7000)
  }
}
// consumer.ts
t.onComplete = (time: number) => {
  console.log(time)
  // access event data
}
t.onComplete = () => {
  console.log('no data')
  // the data argument is optional
}

Removing an Event Handler

To remove an event handler, delete the property.

// consumer.ts
delete t.onComplete

Event Listeners with EventTarget

You may be familiar with DOM element event listeners. For example, you may recognize the following bit of code, which attaches an event handler to a button when clicked:

const btn = document
  .getElementById('some-btn')
  .addEventListener('click', () => {
    // do some stuff
  })

This pattern is available to DOM elements that implement the EventTarget interface. The easiest way to gain access to this interface is through inheritance or composition. In this example, we’ll look at inheritance.

Using ES6 classes, we can have our Timer class implement this interface by extending the EventTarget class. Note that because our class extends the EventTarget class, we need to call super() in the constructor.

We’ll define the 'complete' event as a property. EventTarget works with the Event interface, so we’ll initialize this property as a new Event, passing in the event name.

In the start method after the timeout completes, we’ll fire the ‘complete’ event using the dispatchEvent method, passing in the _complete property. The dispatchEvent method is available on the EventTarget class and takes a single Event argument.

// Timer.ts
export default class Timer extends EventTarget {
  constructor() {
    super()
  }

  private _complete: Event = new Event('complete')

  public start(): void {
    setTimeout(() => {
      this.dispatchEvent(this._complete)
    }, 7000)
  }
}

With our event emitter in place, we can instantiate the Timer and register a handler for the complete event using the addEventListener method. The completeHandler method fires when the 'complete' event fires.

// consumer.ts
import Timer from './Timer'

const t = new Timer()
const completeHandler = () => {
  console.log('timer completed event')
  // do some stuff
}
t.addEventListener('complete', completeHandler)
t.start()

Unlike event property handlers, you can attach many event handlers to a single event.

// consumer.ts
import Timer from './Timer'

const t = new Timer()
const handlerOne = () => {
  console.log('first handler definition')
  // this will execute, too
}
const handlerTwo = () => {
  console.log('second handler definition')
  // this will execute
}
t.addEventListener('complete', handlerOne)
t.addEventListener('complete', handlerTwo)
t.start()

Custom Events

Passing event data in this method involves using CustomEvent in place of the Event interface. Back in the Timer class, let’s change the ‘complete’ event so that it exposes some event data.

The CustomEvent constructor takes an optional ‘detail’ argument. We can use the ‘detail’ object to hold any data we want available on the event.

Instead of declaring and initializing the event at construction, we’ll create it at the time the event fires. This is useful if we want to pass time-sensitive data, such as a timestamp.

Note: the Event and CustomEvent interfaces include a timestamp property. We’re creating a timestamp in the CustomEvent as a demonstration of time-sensitive event data.

// Timer.ts
export default class Timer extends EventTarget {
  constructor() {
    super()
  }

  public start(): void {
    setTimeout(() => {
      this.dispatchEvent(
        new CustomEvent('complete', { detail: { time: Date.now() } })
      )
    }, 7000)
  }
}

In the event handler, we now have access to the detail object on the event.

// consumer.ts
import Timer from './Timer'

const t = new Timer()
const completeHandler = (e: CustomEvent) => {
  console.log('timer completed event', e.detail.time)
}
t.addEventListener('complete', completeHandler)
t.start()

Removing Event Handlers

You can remove an event handler with the removeEventListener method.

// consumer.ts
t.removeEventListener('complete', completeHandler)

Event Listeners with EventEmitter

If you’re working in a server-side context, such as with Node.js, you won’t have access to the EventTarget class. Instead, Node.js has its own version, EventEmitter.

Working with the EventEmitter class is like working with EventTarget. Instead of extending EventTarget, our class will extend EventEmitter. Events fire with the emit method, which takes the event name as a string. You can pass any number of optional arguments as event data.

// Timer.ts
import { EventEmitter } from 'events'
import { setTimeout } from 'timers'

export default class Timer extends EventEmitter {
  constructor() {
    super()
  }

  public start(): void {
    setTimeout(() => {
      this.emit('complete', { time: Date.now() })
    }, 7000)
  }
}
// consumer.ts
import Timer from './Timer'

const t = new Timer()
t.on('complete', () => {
  console.log('timer completed event')
})
t.start()

Which to Use

So, which event technique should you use? Each technique has its pros and cons. Deciding on which to use depends on the application requirements. Do you need a simple and lightweight approach? Use event property handlers. Do you need to register many handlers per event? Use the EventTarget or EventEmitter interface.

To recap, here are some pros and cons to each approach:

Event Property Handler

  • Basic and simple implementation.
  • Doesn’t need to inherit from a base class.
  • Limited to one handler per event.

EventTarget and EventEmitter

  • Allows any number of handlers per event.
  • Must inherit from base event classes.
  • Allows consumer to add and remove event listeners.