What is a JavaScript callback? A visual guide

If you're struggling with JavaScript callbacks, you're not alone. Without an understanding of the fundamentals that drive them, they can be tricky!

With the help of The Great Sync mental model we will learn exactly what callbacks are and what fundamentals make them possible. We will also look at a few examples of where and how they are used.

What is a JavaScript callback function?

A callback is a function with this criteria:

  1. it is passed as an argument to another function
  2. it is executed at a specific point in time, such as before, during or after an operation.

Many people think callbacks are only for asynchronous operations, like a button click callback. This may be the most obvious and common use-case for a callback, but it is not the only use-case.

In fact, they remain an important pattern found in frameworks, libraries, and browser APIs. It's the ability to use functions as values which makes it such a powerful pattern. However, just like any pattern, there are some major drawbacks and pitfalls - you may have heard the term "callback hell".

The JavaScript fundamentals that power callbacks

Let's look at a few important concepts which will help explain what they are.

Reference Values

In JavaScript, values are either island primitives or references. Primitive types (like numbers, strings, and booleans) are value types, while objects (including arrays and functions) are reference types. I have an entire free course explaining this fundamental distinction.

When a reference type is assigned to a variable, the variable doesn't contain the object's value directly. Instead, it holds a reference to the object's location in memory.

An island with a telescope, text "reference pointer", pointing at a flying ship in the sky with text "function"

In the image above, the flying ship is the function object. We can't assign variables (the blue genie) to object ships. Instead, the variables sit on slabs of rock with a telescope pointing at the object ship.

The slab of rock and telescope is the piece of memory used to locate an object. Whenever we need the object, we need to find that slab of rock!

How do we do that? We use named genies (variables). If we can find the genie in scope, we can find the object!

This is what we mean by "reference".

Functions as Values

In JavaScript, functions are first-class citizens. This means they can be assigned to variables, stored in data structures, passed as arguments to other functions, and returned from other functions. This is the fundamental concept that callbacks rely on.

We don't need to pass entire ships around our code. Imagine that! No, we simply need to pass the genie pointer around!

Take a look at this function:

// Assigning a function to a variable
const sayHello = function() {
    console.log('Hello, world!');
};

// Passing a function as an argument to another function
function greet(func) {
    func();
}

greet(sayHello); // Logs: 'Hello, world!'

When the function greet gets executed, we can visualize it like this:

Here are sending genies in a weird vessel into an execution wormhole (). These are our arguments! An argument is a genie pointer, able to locate a primitive island value, or a reference slab rock.

In the code, sayHello is just a genie sent into the greet() wormhole. This means when the argument is executed - func() - we are able to find the sayHello function using its pointer.

This brings me to the next fundamental concept...

Higher Order Functions

A higher order function is a function that takes one or more functions as arguments, returns a function, or both. The name makes them sound a lot more complicated than they are!

greet above can be described as a higher order function - it accepts an argument that is a function.

Many of the common array methods we use everyday are higher order functions:

[1, 2].map((item) => item * 2);

[{ id: 1 }, { id: 2 }].filter((item) => item.id === 2);

[{ id: 1 }, { id: 2 }].forEach((item) => console.log(item));

The arrow functions () => {} are callback functions. And .map, .filter, .forEach are higher order functions.

There cannot be a callback without a higher order function.

What are callbacks used for?

If you recall, our definition for a callback comprises two parts. Firstly, it is an argument passed to a higher order function. Secondly, it is executed at a specific point in time.

So we far we have only discussed the first part. It's the second that explains why callbacks exist in the first place!

Often we want something to happen at a specific moment inside of another function. This could be:

  • at the end of an operation (like at HTTP Get Request)
  • In the beginning, middle or end of a calculation (like a scroll calculation)
  • When the higher order function is ready to provide arguments to the callback (like in a .map array method, on each iteration it will call the function passed to it, providing the array item and its index each time).

You might be thinking, 'well why don't I put that logic INSIDE the higher order function in the first place?'.

This question is apparent in the example below.

Here is a callback pattern for when a css animation has been applied to an element:

function animateElement(element, animationClass, callback) {
    
    const startTime = Date.now();  // start of animation
    element.classList.add(animationClass); // add class

    element.addEventListener('animationend', function handler() {
        element.removeEventListener('animationend', handler);
        element.classList.remove(animationClass);
        const endTime = Date.now();  // end time of animation

        const duration = endTime - startTime;
        callback(duration); // Call the callback function
    });
}

const element = document.getElementById('myElement');
animateElement(element, 'myAnimation', (timeToComplete) => {
    console.log('Animation ended with duration:'  + timeToComplete);
});

The job of animateElement is to apply the css class animation to the element. It also tracks how long it takes that animation to complete, and passes the duration to the callback.

But if we're only logging the duration to the console, why can't we just modify it so we do not need the callback at all? Like this:

function animateElement(element, animationClass, callback) {
    
    const startTime = Date.now();  // start of animation
    element.classList.add(animationClass); // add class

    element.addEventListener('animationend', function handler() {
        element.removeEventListener('animationend', handler);
        element.classList.remove(animationClass);
        const endTime = Date.now();  // end time of animation

        const duration = endTime - startTime;
        // DID THIS INSTEAD. NO NEED FOR A CALLBACK
        console.log('Animation ended with duration:'  + duration);
    });
}

You can do that. But it makes the code weaker...

In the first version, we have a clear division of responsibility. In the second, animateElement has the role of logging in addition to its primary purpose of animating the element.

More importantly, the second version is less versatile. What if we decide we want to change the format of the logged message? Or store it in a database?

animateElement does not care what you do with the duration, just as .forEach does not care what you do with the array item.

In the image below, we can see that when the higher order function executes the callback, it simply puts the genies (arguments) into the argument ship and sends them into the wormhole (). What happens to those genies is not its concern. 🤷

In the animateElement example, the only argument passed to the callback would be the duration.

Why callbacks are confusing

Many people find callbacks confusing. I recently coached a senior with over a decade of experience, who still struggled with callbacks. It is not uncommon. Here are 3 reasons why you might find them difficult:

Confusion #1: how can you add your own arguments?

The biggest mistake is to think you have control over the arguments that are passed to the callback function. Most of the time, you don't. Take the example below.

function clickHandler(e){
    console.log(e, 'clicked!')
}
button.addEventListener('click', clickHandler);

Here you have declared your own function clickHandler. You wrote the function, and coded the logic. This might lead you to falsely believe you can add other parameters, like this:

function clickHandler(e, data){
    console.log(e, 'clicked!')
    console.log(data)
}

You can add as many parameters as you like, it won't make a difference. It is up to the higher order function to fill the parameter seats with genie arguments. In the case of the button.addEventListener, there will only be one genie argument for the click event object.

Unless you can change the higher order function, you cannot change what arguments are received. Most of the time, you are using higher order functions from browser APIs or third party libraries.

All you can do then is read the documentation and see what arguments your callback will receive.

Confusion #2: You are not sure what parameter name to use

In The Great Sync mental model, parameters are simply empty seats in a vessel that will eventually be sent into an execution context as arguments. You can name these seats how you want. They are just labels for the arguments who eventually end up sitting there.

// all of these parameter names are valid
function clickHandler(e){}
function clickHandler(event){}
function clickHandler(eventObj){}
function clickHandler(data){}

[ 1,2 ].map( ( i ) => i * 2 )
[ 1,2 ].map( ( item ) => item * 2 )
[ 1,2 ].map( ( thing ) => thing * 2 )

Confusion #3: when is the callback executed?

It can be difficult to know when , if ever, a callback is executed. This applies especially to callbacks for events.

The point of a callback is you don't need to worry about when or how it is called. That's the responsibility of the higher order function. Your concern is only to write the logic that does something with the values provided to the callback.

Examples of JavaScript callbacks used

The ability to pass functions as values around, and execute them at specific moments in time, makes them extremely handy. They remain a core JavaScript feature. You will see them being used everywhere, in modern libraries, browser APIs and by other JavaScript language patterns. Let's take a look at a few...

Event Handlers

Event handlers in JavaScript are functions that are called in response to certain events occurring within the application, such as a button being clicked, a form being submitted, or a page finishing loading. These functions are often set up as callbacks.

// Get a reference to the element we want to listen for the scroll event on
const content = document.getElementById('content');

// Add a scroll event listener to the element
content.addEventListener('scroll', function() {
    console.log('Content is being scrolled!');
});

// Optionally, you can add some logic to trigger a specific action when the user scrolls
content.addEventListener('scroll', function() {
    if (content.scrollTop + content.clientHeight >= content.scrollHeight) {
        console.log('User has scrolled to the bottom of the content!');
    }
});

Just remember that these callbacks technically do not run immediately after the event. 🤔

After an event happens, the callback goes into a callback queue, and the event loop will execute it when the stack is clear and its at the front of the queue. This is an entire blog post on its own.

Timeouts

The setTimeout function in JavaScript is a way to schedule a function to run after a specified amount of time. It uses callbacks to specify the code that should run after the timeout.

setTimeout(function() {
    console.log('2 seconds have passed');
}, 2000);

Promises

Promises in JavaScript represent a future value. They are objects that may produce a single value at some point in the future: either a resolved value or a reason why it’s not resolved (an error). Promises can be chained together using .then callbacks.

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('Promise resolved');
    }, 2000);
});

promise.then(result => {
    console.log('This is a callback. Here is the result: ' + result);
}).catch(error => {
    console.log('This is a callback. Here is the error: ' + error);
});

Open Source Libraries Relying on Callbacks

Lodash

Lodash is a utility library that takes the hassle out of working with arrays, numbers, objects, strings, etc. Lodash’s modular methods are great for iterating arrays, objects, and strings; manipulating and testing values; and creating composite functions. It uses callbacks in many of its functions.

const _ = require('lodash');

let users = [
  { 'user': 'barney', 'age': 36, 'active': true },
  { 'user': 'fred',   'age': 40, 'active': false }
];

// The _.filter method iterates over elements of collection, returning an array of all elements predicate returns truthy for.
let activeUsers = _.filter(users, function(user) { return user.active; });

console.log(activeUsers);
// => objects for ['barney']

// The _.find method iterates over elements of collection, returning the first element predicate returns truthy for.
let firstActiveUser = _.find(users, function(user) { return user.active; });

console.log(firstActiveUser);
// => object for 'barney'

In these examples, the callback function is used to determine the criteria for filtering and finding objects in an array.

jQuery

jQuery, a fast, small, and feature-rich JavaScript library, makes things like HTML document traversal and manipulation, event handling, and animation much simpler with an easy-to-use API. It uses callbacks in many of its functions.

// jQuery's ready method takes a callback that fires when the DOM is ready
$(document).ready(function() {
    console.log('DOM is ready');
});

Express.js

Express.js, a minimal and flexible Node.js web application framework, provides a robust set of features for web and mobile applications. It uses callbacks for route handling and middleware.

const express = require('express');
const app = express();

// The callback handles the HTTP request and response
app.get('/', function(req, res) {
    res.send('Hello, World!');
});

app.listen(3000);

Mongoose

Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose operations such as queries and saves take a callback function, and the callback is called when the operation completes.

const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');

const Cat = mongoose.model('Cat', { name: String });

const kitty = new Cat({ name: 'Zildjian' });
kitty.save(function (err) {
    if (err) {
        console.log(err);
    } else {
        console.log('meow');
    }
});

Socket.IO

Socket.IO enables real-time, bidirectional and event-based communication. It uses callbacks to handle real-time events.

const io = require('socket.io')(80);

io.on('connection', function (socket) {
    socket.on('ferret', function (name, callback) {
        callback('woot');
    });
});

Callback Hell and the Pyramid of Doom

Callback hell, also known as the "Pyramid of Doom", refers to the situation where callbacks are nested within other callbacks several levels deep, making the code difficult to read and maintain. This often occurs when dealing with asynchronous operations that depend on each other, leading to deeply nested code structures.

Nesting callbacks makes the code very tricky to read, and leads to a higher chance of bugs.

Example of Callback Hell

Imagine you need to perform a series of asynchronous operations in sequence, such as making API requests, updating the DOM, and logging results. Using nested callbacks, the higher order function accepting a callback function might look something like this:

function fetchData(url, callback) {
    fetch(url)
        .then(response => response.json())
        .then(data => callback(null, data))
        .catch(err => callback(err));
}

This uses the browser's fetch api to make an HTTP Get request to a provided url, and executes the callback function on either the successful response, or the error.

We want to do the following things in order:

  1. fetch the data
  2. update the DOM using the data. It takes a callback to tell us when this is complete
  3. log the result after 1 second. It takes a callback to for when the timer is finished

That logic might look something like this:

fetchData('https://api.example.com/data', (err, data) => {
    if (err) {
        console.error('Error fetching data:', err);
    } else {
        updateDOM(data, (err, result) => {
            if (err) {
                console.error('Error updating DOM:', err);
            } else {
                logResult(result, (err, finalResult) => {
                    if (err) {
                        console.error('Error logging result:', err);
                    } else {
                        console.log(finalResult);
                    }
                });
            }
        });
    }
});

Do you see where the name "Pyramid of Doom" comes from now? 😂 This is horrible code.

Avoiding callback hell with promises

JavaScript promises allow us to structure these callbacks into a coherent and logical flow:

// Chaining Promises to avoid callback hell
fetchData('https://api.example.com/data')
    .then(updateDOM)
    .then(logResult)
    .then(finalResult => console.log(finalResult))
    .catch(err => console.error('Error:', err));

Notice, however, that callbacks are still being used. Each .then is a higher order function, which accepts another function as an argument. When it executes the callback, it will pass the result in.

Same fundamentals. Different pattern.

We're not done yet. This nested .then structure is still problematic. The new async await syntax provides a cleaner way of working with promises:

async function main() {
    try {
        const data = await fetchData('https://api.example.com/data');
        const updateResult = await updateDOM(data);
        const logResultMessage = await logResult(updateResult);
        console.log(logResultMessage);
    } catch (err) {
        console.error('Error:', err);
    }
}

So, are callbacks bad?

Absolutely not. "Callback hell" is just something to be wary of when writing nested callbacks. But the concept of passing functions to other functions is a core strength of JavaScript, and is a pattern that continues to be used in a wide variety of situations. Understanding how they work and how to use them effectively is an important part of becoming a proficient JavaScript developer.

If you are interested in learning more about the fundamentals that drive callbacks, check out my free short course on a Visual Deep Dive into Objects.


© 2024 Code Imagined - The Great Sync. All Rights ReservedView the Terms & ConditionsView the Privacy Policy
Dev Kylo