6.102
6.102 — Software Construction
Spring 2024

Reading 17: Callbacks and Graphical User Interfaces

Software in 6.102

Safe from bugsEasy to understandReady for change
Correct today and correct in the unknown future. Communicating clearly with future programmers, including future you. Designed to accommodate change without rewriting.

Objectives

In this reading we talk about callbacks, in which an implementer calls a function provided by the client. We discuss this idea in the context of promises, showing how await is implemented using callbacks. We’ll also see how callbacks are used in graphical user interfaces (GUIs), where a kind of callback function called a listener is used to respond to incoming input events from the user.

We’ll see more instances of callbacks in the next class, when we talk about web servers.

Callbacks

A callback is a function that a client provides to a module for the module to call. This is in contrast to the normal flow of control, in which the client is doing all the calling: calling down into functions that the module provides. With a callback, the client is providing a piece of code for the implementer to call.

We already saw one kind of callback in Functional Programming: the function passed to map or filter or reduce, which is called repeatedly for each element of the sequence. This is a synchronous callback, because it is called only during the execution of the map/filter/reduce, and then not used thereafter. The use of the callback is synchronized within the call and return of the map/filter/reduce.

This reading considers asynchronous callbacks, which are saved by the module for a future call, after the function that received the callback has already returned.

Here’s one analogy for thinking about the difference between synchronous and asynchronous. Normal (synchronous) function calling is like picking up the phone and calling a service, like calling your bank to find out the balance of your account. You give the information that the bank operator needs to look up your account, they read back the account balance to you over the phone, and you hang up. You are the client, and the bank is the module that you’re calling into.

Sometimes the bank is slow to give an answer. You’re put on hold, and you wait until they figure out the answer for you. This is like a synchronous function call that waits until it is ready to return.

But sometimes the task may take so long that the bank doesn’t want to put you on hold. Then the bank will ask you for a callback phone number, and they will promise to call you back with the answer, at some unpredictable point in the future. This is analogous to providing an asynchronous callback function.

Sometimes a callback function will be called only once (or at most once), as a response to a one-time request, as in this account-balance example. But a callback function may also be called repeatedly, whenever a certain event happens. An analogy for this kind of callback might be account fraud protection, where the bank calls you at your registered phone number whenever a suspicious transaction occurs on your account. We will see both kinds of asynchronous callbacks in this reading.

Timer callbacks

A simple example that uses a callback is the built-in JavaScript function setTimeout, which takes two arguments: a callback function and the number of milliseconds to wait before executing the function:

setTimeout(
    () => console.log("beep!"),
    3000
);

Here, the callback function is the function object created by the lambda expression () => console.log("beep!").

Now consider the output of running the following code at a TypeScript prompt:

> setTimeout(() => console.log("beep!"), 3000); console.log("boop!");
boop!
3 seconds later
> beep!

Notice that the callback is asynchronous. The call to setTimeout() itself doesn’t wait for 3000 milliseconds (3 seconds) before printing boop!. Instead, it starts an internal timer, stores a reference to the callback function, and then prints boop!. Once the internal timer expires three seconds later, then the callback function is finally called, printing beep!.

A side note: setTimeout is unusual for expecting its callback function as the first argument. It’s an unfortunate design choice, since when a call to setTimeout is written on one line with a lambda expression (as in the example just above), it can be hard to tell where the lambda expression ends and the remaining arguments to setTimeout start. Usually a callback function appears at the end of a parameter list, and the remaining examples in this reading all use that convention instead.

To see an example of defining a function that takes a callback, let’s create a countdown timer:

/**
 * Start a timer that ticks once per second until it expires.
 * @param ticks      duration of the timer in seconds, must be an integer >= 0
 * @param callback   callback function, initially called synchronously, 
 *                   then called again after each tick of the timer, 
 *                   each time passing the number of seconds left until the timer expires.
 *                   ticksLeft must be an integer in [0, ticks].
 */
function countdown(ticks: number, callback: (ticksLeft: number) => void): void

Look closely at the type of the callback parameter: this is a function type. In this case, countdown() is expecting a callback function that takes a single number argument and returns void.

Here’s how it might be called at a TypeScript prompt:

> countdown(3, (n: number) => console.log(n));
3
>
one second later
2
one second later
1
one second later
0

Notice where 3 is printed in the transcript above: before the next prompt > was displayed, because the spec indicates that the first callback should be called synchronously, before countdown() returns. The remaining calls to the callback happen asynchronously, as the timer counts down to completion.

The exercises below explore the behavior of the setTimeout() and countdown() callbacks, and show how to implement countdown() using setTimeout().

reading exercises

Time to print
setTimeout(() => console.log("A"), 4000);
setTimeout(() => console.log("B"), 1000);
console.log("C");

In what order are A, B, and C printed by this code?

(missing explanation)

What is the total duration of the program, from the start of the code to the final line printed, rounded to the nearest second?

(missing explanation)

Callback type

Recall the function signature for countdown, with particular attention to the callback parameter:

function countdown(ticks: number, callback: (ticksLeft: number) => void): void

and suppose the client has previously defined this function:

function myCallback(ticksLeft: number) { console.log(ticksLeft); }

Which of the following are true statements about the callback that the client can pass to countdown?

(missing explanation)

(missing explanation)

Counting down

Let’s consider how countdown() might be implemented using setTimeout(). Here is a recursive implementation:

/**
 * Start a timer that ticks once per second until it expires.
 * @param ticks      duration of the timer in seconds, must be an integer >= 0
 * @param callback   callback function, initially called synchronously, 
 *                   then called again after each tick of the timer, 
 *                   each time passing the number of seconds left until the timer expires.
 *                   ticksLeft must be an integer in [0, ticks].
 */
function countdown(ticks: number, callback: (ticksLeft: number) => void): void {
    const millisecondsPerTick = 1000;
    if ( ticks > 0 ) {
        setTimeout(_____________, millisecondsPerTick);
    }
    callback(ticks);
}

Which of the following expressions could be put in the blank for the recursive case, so that the implementation works for ticks > 0?

(missing explanation)

What does this code do when called with countdown(0, (ticksLeft) => console.log(ticksLeft))?

(missing explanation)

Counting down again

Now let’s consider an imperative implementation of countdown(), which is currently buggy:

    /**
     * Start a timer that ticks once per second until it expires.
     * @param ticks      duration of the timer in seconds, must be an integer >= 0
     * @param callback   callback function, initially called synchronously, 
     *                   then called again after each tick of the timer, 
     *                   each time passing the number of seconds left until the timer expires.
     *                   ticksLeft must be an integer in [0, ticks].
     */
    function countdown(ticks: number, callback: (ticksLeft: number) => void): void {
        const millisecondsPerTick = 1000;

        function doTick() {
            callback(ticks);
            if ( ticks > 0 ) {
                setTimeout(doTick, millisecondsPerTick);
            }
        }

        doTick();
    }

What does this code do when called with countdown(0, (ticksLeft) => console.log(ticksLeft))?

(missing explanation)

What does this code do when called with countdown(1, (ticksLeft) => console.log(ticksLeft))?

(missing explanation)

Counting down again, but not ad infinitum

The bug in the imperative implementation is that it never decrements ticks. Let’s try fixing it:

    /**
     * Start a timer that ticks once per second until it expires.
     * @param ticks      duration of the timer in seconds, must be an integer >= 0
     * @param callback   callback function, initially called synchronously, 
     *                   then called again after each tick of the timer, 
     *                   each time passing the number of seconds left until the timer expires.
     *                   ticksLeft must be an integer in [0, ticks].
     */
    function countdown(ticks: number, callback: (ticksLeft: number) => void): void {
        const millisecondsPerTick = 1000;
//A
        function doTick() {
//B
            callback(ticks);
//C
            if ( ticks > 0 ) {
//D
                setTimeout(doTick, millisecondsPerTick);
//E
            }
        }
//F
        doTick();
//G
    }

Suppose you insert the statement --ticks; at just one of the locations marked A-G. Which of the possible insert locations would fix the bug, so that countdown() obeys its spec?

(missing explanation)

Event loop

These timer callbacks allow us to uncover a fundamental aspect of the TypeScript/JavaScript programming model. At the core of the JavaScript runtime system is an event queue, a first-in-first-out queue that stores events that have arrived from various sources. The queue is serviced by an event loop that repeatedly checks this queue for newly-arrived events, and calls appropriate callback functions.

One source of events are the timers created by setTimeout. The expiration of a timer causes a “timer done” event to be placed at the end of the queue, along with its callback function. When the event loop eventually retrieves this event from the top of the queue, it calls the callback function, finally discharging the obligation of setTimeout’s contract.

Later in this reading, we will see another source of events: a graphical user interface. For JavaScript running in an HTML web page, the user’s keypresses, mouse movements and presses, or finger-touches are placed at the end of the event queue, and eventually handled by callback functions.

And still another source of events are input and output to the filesystem and network, which we will see in a future reading.

All our TypeScript/JavaScript code is actually running inside the event loop — even the start of the program is initiated from the event loop. In order for the program to work correctly, all our code must also return to the event loop in a timely way, so that the event loop can move on to the next event, and continue processing subsequent timer expirations or GUI/file/network events.

If any of our code blocks — holding onto the flow of control and not returning to the caller for a significant period of time — then the event loop stops processing events. Timer callbacks don’t fire, the user interface stops responding, and network connections stop delivering data. If you have ever seen your mouse cursor turn into a spinning beach ball or an hourglass, that’s a sign of a program whose event loop has become blocked, so that it is no longer processing events. It’s bad.

Here’s a simple function that would block the JavaScript event loop:

/** Wait for `milliseconds` to pass before returning. */
function busyWait(milliseconds: number): void {
    const now = new Date().getTime(); // new Date().getTime() is always the current clock time in milliseconds
    const deadline = now + milliseconds;
    while (new Date().getTime() < deadline) {
        // do nothing, just wait until the clock reaches the deadline time
    }
}

A call to busyWait(10000) spins and spins around its while loop, watching the clock, until 10 seconds have passed. During that time, events will pile up in the event queue, but the event loop will not be able to process them, because busyWait() has not returned. Once busyWait() returns its caller (which then returns to its caller, and so forth all the way back up to the event loop, which is the original caller of everything in JavaScript), then the event loop can finally peel off the next waiting event and call its callback function, and life can return to normal. But all those timer callbacks that piled up will fire too late!

Recall that spinning in a loop waiting for something to change externally is called busy-waiting, and it is a very bad code smell for an event-loop runtime system like JavaScript. Code like busyWait() needs to be rewritten. It should use a callback triggered by the change (as the built-in JavasScript function setTimeout() does), or a promise triggered by the change (like our asynchronous version timeout()).

reading exercises

Blocking the event loop

The busyWait() function is definitely a bad design for TypeScript/JavaScript. But even well-meaning functions can inadvertently block the event loop:

/** @returns a random prime number with `n` decimal digits. */
function randomPrime( n: number ): bigint;

Suppose randomPrime() is implemented by a guess-and-test strategy, generating n-digit numbers at random and testing them for primality until it finds a prime one. So randomPrime(1000) may take an unpredictable amount of time to run.

Which possible behaviors might you see from the example code below? Choose all that apply.

setTimeout(() => console.log("A"), 1000);
console.log(randomPrime(1000));
setTimeout(() => console.log("B"), 2000);

(missing explanation)

Exceptions from synchronous callbacks

One difference between synchronous and asynchronous callbacks is how they behave with exception handling.

Let’s first consider a synchronous callback that always throws an exception, used by map:

function alwaysThrows(n: number) { 
    throw new Error("boom!");
}

[1, 2, 3].map(alwaysThrows);

Which of the following places to put a try-catch statement will catch the exception thrown by alwaysThrows? Assume the choices are independent, i.e. the try-catch is put in only one place in the code.

(missing explanation)

Exceptions from asynchronous callbacks

Now let’s consider an asynchronous callback, used by setTimeout:

function alwaysThrows() { 
    throw new Error("boom!");
}

setTimeout(alwaysThrows, 1000);

Which of the following places to put a try-catch statement will catch the exception thrown by alwaysThrows? Assume the choices are independent, i.e. the try-catch is put in only one place in the code.

(missing explanation)

Using callbacks with promises

Prior to the introduction of promises in JavaScript, callback functions were the most common way to implement asynchronous function behavior, and there are still many uses of callbacks in the JavaScript library ecosystem.

And it turns out that under the hood, promises also use callbacks. Let’s dig into the Promise abstract data type, to understand more about how it works and how it interacts with await and async function.

An abstract data type is defined by its operations, and it turns out that a Promise<T> has one key operation, called then. Here is the simplest version of then:

interface Promise<T> {
  then(callback: T => U): Promise<U>;
}

The then operation is a Promise producer. It is called on an existing promise expected to result in a value of type T, and attaches a callback function to that promise. The callback function consumes the T value the promise eventually produces, and generates a value of some other type U (possibly the same as T, of course).

Here’s an example of then in action:

const promise : Promise<string> = fs.promises.readFile('account', { encoding: 'utf-8' });

const biggerPromise : Promise<number> = promise.then(function(data: string) {
  return parseInt(data);
});

Here, the original promise, which is reading a file into a string, is composed by then with a callback function that parses that string into a number. The resulting biggerPromise represents the entire computation – both reading the file and parsing it into a number – and promises to eventually produce that number.

It helps to think about then as a composition operation for asynchronous computations. Conventional function composition takes two functions f and g and composes them into a new function g ⚬ f, such that (g ⚬ f)(x) = g(f(x)). If f: S → T and g: T → U, then the type of the resulting composition is g ⚬ f: S → U.

In much the same way, then composes one computation f, represented by a promise of its return value Promise<T>, with another computation g that expects to consume that T value and produce a U value. The result of the composition is a combined computation that promises to eventually produce that U value, so its type is Promise<U>.

It may also help to think about Promise<T> like a one-element list that (eventually) has a T value in it. From that point of view, then(g) is like map(g) – once the T element arrives, the g callback is called on it, to produce a U value that ends up as the single element of the resulting Promise<U>. (One place this analogy breaks down, however, is with Promise<void>, since such a promise never actually has any element value. Using then(g) on a void promise still calls g when the promise fulfills, but doesn’t pass any value to g.)

We need to expand this notion of then() in one important way. The callback function doesn’t have to return an already-computed U value. It can instead return a promise of its own, of type Promise<U>:

interface Promise<T> {
  then(callback: T => U|Promise<U>): Promise<U>;
}

The callback might need to do this if computing the U also requires another long-running computation that needs to run in the background. Web page retrieval offers a good example of this, because getting the Response from a fetch() call just means you’ve managed to connect to the web server. Actually downloading the full page may take a while longer. Here’s how that promise chain might look:

const promisedResponse: Promise<Response> = fetch('http://www.mit.edu/');

const promisedText: Promise<string> = 
  promisedResponse.then(function(response: Response): Promise<string> {
    // we've made a connection, which is why the `response` object exists
    const downloadingPromise: Promise<string> = response.text();
    // but we may have to wait some more for the actual data, hence `downloadingPromise`
    return downloadingPromise;
  });

Note the several promises shown in this example code:

  • promisedResponse represents the computation that is making the initial connection to www.mit.edu
  • downloadingPromise represents the computation that downloads the MIT homepage after the connection has already been made
  • promisedText represents the composition of both computations: the initial connection followed by the download. The final value of promisedText comes from the value of downloadingPromise, the text of the webpage.

Even though the callback function passed into promisedResponse.then() doesn’t use async or await, it still returns a promise, so it’s an asynchronous function.

Going back to the function composition analogy, if promisedResponse and downloadingPromise are like the functions f and g, respectively, then promisedText is like the composition g ⚬ f.

You can call then as many times as you want on a promise, to attach different callbacks that might do different things with the promise’s value.

You can also call then regardless of what state the promise is currently in. Most often, the promise is still pending, so the then callback will run sometime in the future. If the promise is already fulfilled, the then callback will run sooner – but still asynchronously. The callback function is never called synchronously by then. Instead, then always returns first, and later, after control returns to the event loop, the callback function will be called.

The then operation is the fundamental operation of a promise. then() is the only way to access the value that a promise computes. (await actually uses then, as we’ll see in a moment.) This is partly a safety property, because it ensures that client code can never look inside a promise and see a missing value. But more important, then() is a feature of concurrency design. It allows a concurrent computation to be built up by a sequence of composed computations – a sequence of then() callbacks, which can be interleaved in controlled, predictable ways.

reading exercises

Then more timing

Let’s return to our friend timeout, which returns a timer promise:

// returns a promise that becomes fulfilled `milliseconds` milliseconds after the call to timeout
function timeout(milliseconds: number): Promise<void>

Approximately how long does the following code take to reach the point where it prints done?

timeout(1000).then(function() {
  console.log('done');
});
timeout(2000);

(missing explanation)

timeout(1000).then(function() {
  return timeout(2000);
});
console.log('done');

(missing explanation)

timeout(1000).then(function() {
  return timeout(2000).then(function() {
    console.log('done');
  });
});

(missing explanation)

Unpacking an asynchronous function

The then operation now gives us enough power to see what await and async function mean, and how they use promises and callbacks to construct a computation.

Here’s an async function containing a line that awaits a promise, and then does some computation on the result of the promise:

async function getBalance(): Promise<number> {
    const promise: Promise<string> = fs.promises.readFile('account', { encoding: 'utf-8' });
    const data: string = await promise;
    const balance: number = parseInt(data);
    return balance;
}

When await is encountered in the execution of a function like this, TypeScript takes the remainder of the computation that the function would do, and wraps it up into a then callback. So this code:

const data: string = await promise;
const balance: number = parseInt(data);
return balance;

becomes something like this:

promise.then(function(data: string) {
  const balance: number = parseInt(data);
  return balance;
});

Instead of spinning its wheels waiting for the file-loading promise to fulfill, await creates a composite promise, which combines the file-loading computation with the parseInt computation. It then immediately returns control to the caller, along with this composite promise. So the equivalent code, without await or async, looks something like this:

function getBalance(): Promise<number> {
    const promise: Promise<string> = fs.promises.readFile('account', { encoding: 'utf-8' });
    return promise.then(function(data: string) {
      const balance: number = parseInt(data);
      return balance;
    });
}

Note that this version of getBalance is fairly simple because it’s just straight-line code. When await is used inside a loop, then the “rest of the function’s computation” includes the remaining iterations of the loop, which can’t be easily shown by a syntactic transformation like this, because it chops the loop in pieces. But this example shows semantically what await means.

Note also that this version of getBalance doesn’t have the async keyword any more, because we’re showing what the async keyword means in terms of lower-level TypeScript code. But this version of getBalance is indeed still asynchronous, because it returns a promise representing a concurrent computation that has yet to complete.

To summarize, when you write async function instead of function, TypeScript/JavaScript internally transforms the function’s behavior:

  • await expressions are transformed into returning a promise composed with then();
  • return/throw statements are transformed into fulfilling/rejecting (respectively) that promise
    • a function that returns by falling off the end of its body is transformed to fulfill its promise before doing so

HTML and the Document Object Model

Asynchronous callbacks are heavily used in graphical user interfaces. To understand how, we need to start by looking at how user interfaces are structured, using HTML web pages as an example.

HTML consists of elements, which are delimited by a start tag and end tag. For example, here is how a button is described in HTML:

<button id='drawButton'>Draw</button>

… which the web browser renders like this:

The start tag often contains attributes, like id here. The id attribute is a unique identifier for the element. Other attributes may specify other properties of the element. For example, here is a 256-by-256-pixel drawing surface:

<canvas id="drawingCanvas" width="256" height="256"></canvas>

(The <canvas> element has the Canvas drawing interface that we are using in ps3.)

HTML elements are nested in other HTML elements, to form a tree. Below is a snippet of HTML that groups two buttons together in one row, and the canvas in another row, using the <div> grouping element. The way it might render in a web page is shown on the right.

<div>
    <button id="drawButton">Draw</button>
    <button id="clearButton">Clear</button>
</div>
<div>
    <canvas id="canvas" width="256" height="256">
    </canvas>
</div>

The tree of elements is called the Document Object Model (DOM). Each element of the tree occupies a certain portion of the screen, generally a rectangular area called its bounding box. The tree is not just an arbitrary hierarchy, but is in fact a spatial one: child elements are typically nested inside their parent’s bounding box.

Virtually every GUI system has some kind of tree of visual elements. The element tree is a powerful structuring idea, which is loaded with responsibilities in a typical GUI:

Output. Attributes and children of the element affect what the element displays on the screen. For example, the label on the button is the text found between its start tag and end tag.

Input. Elements can have input handlers that respond to mouse and keyboard input from the user, and to other events. More on this in a moment.

Layout. The DOM controls how elements are laid out on the screen, i.e. how their bounding boxes are assigned. An automatic layout algorithm automatically calculates the position and size of each element, controlled by style rules written in Cascading Style Sheets (CSS). CSS layout is outside the scope of what we’ll be talking about in 6.102; 6.104 [formerly 6.170] or IAP Web.Lab are good places to learn it.

Connecting to TypeScript

To make an interactive user interface, HTML can also include JavaScript code (or TypeScript which has been compiled into JavaScript). The code can obtain references to elements in the tree using document.getElementById():

const drawButton: HTMLButtonElement = document.getElementById('drawButton');

DOM elements are mutable, so the code can reassign instance variables or call mutators to add and remove elements from the tree, or change their appearance. For example, this code changes the button’s label:

drawButton.text = 'Draw Randomly';

Input handling

Input is handled differently in GUIs than in typical command-line or text-input user interfaces. A typical text UI has an input loop that displays a prompt, reads a command typed by the user, parses it, and decides how to direct it to different modules of the program.

If a GUI were written that way, it might look like this (in pseudocode):

while (true) {
    read mouse click
    (x,y) = position of mouse click
    if (mouse was clicked on the Draw button) drawRandomShape();
    else if (mouse was clicked on the Clear button) clearCanvas();
    else if (mouse was clicked somewhere on the canvas) drawShapeAtPosition(x, y);
    ...
}

But in a GUI, we don’t directly write this kind of code, because it’s not modular. GUIs are put together from a variety of library components – buttons, scrollbars, textboxes, menus – that need to be self-contained and handle their own input. Deep inside the graphical user interface library is an input loop that is reading from the mouse and keyboard, and passing those input events to the appropriate components of the GUI.

In order to make a button do something when it is clicked, you attach a listener to it:

drawButton.addEventListener('click', (event: MouseEvent) => {
  drawRandomShape();
});

GUI event handling is an instance of the Listener pattern. In the Listener pattern:

  • An event source generates a stream of discrete events.
  • One or more other modules subscribe (or listen) to the stream of events, providing a function to be called when a new event occurs.

In this example:

  • the button is the event source;
  • its events are button presses;
  • the listener is a function provided by the client.

An event often includes additional information which might be bundled into an event object (like the MouseEvent here) or passed as parameters to the listener function.

When an event occurs, the event source distributes it to all subscribed listeners, by calling their listener callbacks.

So the control flow through a graphical user interface proceeds like this:

  • The event loop reads input from mouse and keyboard. In HTML/JavaScript, like most other GUI frameworks, this loop is actually hidden from you. It’s buried inside the JavaScript runtime system, and listeners appear to be called magically.
  • Each listener does its thing (which might involve e.g. mutating objects in the user interface), and then returns immediately to the event loop.

The last part – listeners return to the event loop as fast as possible – is very important, because it preserves the responsiveness of the user interface.

The Listener design pattern isn’t just used for button presses. Every GUI object generates events, often as a result of some combination of low-level input events sent by input devices, like a mouse, keyboard, or touchscreen. For example, in HTML:

  • the mouse sends low-level events mousedown (the mouse button was pressed down) and mouseup (when the button was released)
  • the keyboard sends similar low-level events keydown and keyup when keyboard keys are pressed and released
  • a button (<button>) sends a click event after it receives both mousedown and mouseup.
  • a dropdown menu (<select>) sends an input event when the user selects a different element from the dropdown menu (whether by mouse or by keyboard)
  • a textbox (<input type=text>) sends an input event when the text inside it changes, as a result of keyboard or mouse events.

reading exercises

Listeners

Put the following items in order according to when they would happen during the setup and execution of an HTML graphical user interface.

const launchButton: HTMLButtonElement = document.getElementById('launchButton');

launchButton.addEventListener('click', launchMissiles);

launchMissiles() function is called

HTML code <button id="launchButton"> is interpreted by the web browser to create an HTMLButtonElement object in the DOM

Mouse click on the launch button is received by the input event loop

(missing explanation)

Don’t call me, I’ll call you

The listener in this section is an example of a callback, where the client of a module provides a piece of code for that module to call:

drawButton.addEventListener('click', (event: MouseEvent) => {
  drawRandomShape();
});

What is the client in the example?

(missing explanation)

What is the piece of code in the example?

(missing explanation)

What is that module in the example?

(missing explanation)

Event sources

The listener pattern is a general programming idea, and there are two ways it appears in TypeScript/JavaScript programming:

  • in web pages, as we have just seen, objects that can act as event sources implement the addEventListener() method, which is part of the EventTarget interface.

  • in Node, event sources implement the on() method, which is part of EventEmitter. And addListener() is a synonym for on().

The addEventListener() and on()/addListener() methods have essentially the same parameters: a string naming the type of the event, and a callback function that will be called when the event occurs.

Pitfalls in listeners

There are a few common pitfalls in the listener design pattern, particularly when used in GUIs, that are important to be aware of.

Listeners should run quickly and return quickly. If a listener function responds to an event by doing a long, slow computation, then it will stall the user interface. Since the listener was called by the GUI event loop, the event loop will not be able to continue processing input events until the listener returns.

For example, suppose the draw button’s listener drew a large number of random shapes:

drawButton.addEventListener('click', (event: MouseEvent) => {
    for (let i = 0; i < 1_000_000; i++) {
        drawRandomShape();
    }
});

Drawing a million rectangles may involve a long, long pause. During that time, the web page appears frozen. Mouse clicks don’t seem to work, scrolling doesn’t work, keypresses don’t seem to be handled. On many platforms, the operating system changes the mouse cursor to an hourglass or colorful spinning beachball to show that the user interface is stuck.

Once the million rectangles are finally all drawn, and the listener function returns, then the event loop resumes processing. All the input events you tried to do while it was stuck were stored in a queue, so it will eventually catch up.

The lesson here is: in graphical user interfaces, listener functions must run quickly and return quickly, typically within a few milliseconds.

If a listener needs to do a long-running computation, it should use concurrency, which we talked about in a previous class.

Be careful about cycles. Sometimes it is necessary to have two GUI elements that mutually depend on each other, like the textbox and slider in the code below:

Size:
<div>
    Size:
    <input type="number" id="sizeTextbox" value="10" min="1" max="50" maxlength="2"> 
    <input type="range" id="sizeSlider" value="10" min="1" max="50">
</div>

In this design, the user should be able to change the size either by typing a number in the box, or by dragging the slider. Either way of doing it should update the other element as well, so that they remain consistent. So it’s tempting to attach listeners like this, to propagate the value from the textbox to the slider, and vice versa:

sizeTextbox.addEventListener('input', (event: ChangeEvent) => {
    // make the slider match the updated textbox
    sizeSlider.value = sizeTextbox.value;
});
sizeSlider.addEventListener('input', (event: ChangeEvent) => {
    // make the textbox match the updated slider
    sizeTextbox.value = sizeSlider.value;
});

This creates a cycle of listeners. If the textbox changes, its listener responds by mutating the slider, which might in turn trigger the listener for the slider, which mutates the textbox, which might then trigger the textbox listener again. There is a risk of an infinite loop.

In this case, the cycle is broken by specification: the input event is specified to be sent only when the user changes the value of the textbox or slider, using mouse or keyboard input. Mutations of the value caused by code do not trigger the event. So there is no infinite loop in this particular case, but you should watch out for the possibility in general.

Finally, don’t forget to clean up. You may reach a point in your program when you’d like to stop listening. EventTarget’s addEventListener() and EventEmitter’s on()/addListener() methods have complementary methods removeEventListener() and off()/removeListener(). Failing to remove listeners when they are no longer necessary may slow down or cause bugs in your user interface.

Summary

  • Callbacks are another example of first-class functions, where we treat functions just as we treat data: passing them to and returning them from other functions, and storing them to call later.
    • They can be synchronous or asynchronous
  • TypeScript/JavaScript use an event loop and event queue to store events and execute code.
  • GUI event handling (e.g. HTML) uses the Listener pattern, where modules can listen for different events and provide callback functions.
    • Listeners must return quickly to give control back to the event loop, and should not be in a cycle with another listener.

These ideas connect to our three key properties of good software as follows:

  • Callbacks make code more ready for change by allowing clients to provide their own code to run when an event occurs, so that the behavior doesn’t have to be hard-coded into the implementation beforehand.

  • Writing a single giant input loop to handle every possible event in a large system is neither safe from bugs nor easy to understand: that single piece of code becomes an all-knowing all-controlling behemoth. Callbacks allow each module in the system to be responsible for their own events.