Callbacks in Javascript: How and Why it Powers Functions

Callbacks in Javascript: How and Why it Powers Functions

·

5 min read

In JavaScript, callbacks are the unsung heroes we use absentmindedly in many of our functions and methods. They enable flexibility, reusability, and asynchronous programming. Understanding callbacks is crucial - not just how to implement them but how and why they work the way that they do.

I was recently asked to write a javascript method that functions similarly to Array.prototype.filter and Array.prototype.reduce, spoiler alert, I had to understand in-depth how callbacks work and decided to write about it. You can find the code here.


What Is a Callback?

At its core, a callback is a function passed as an argument to another function. The receiving function can then execute the callback at a specific time or condition. Callbacks allow for dynamic and reusable code by enabling behaviour to be customised at runtime.

Example: A Simple Callback

function greet(name, callback) {
    console.log(`Hello, ${name}!`);
    callback();
}

function sayGoodbye() {
    console.log("Goodbye!");
}

greet("Alice", sayGoodbye);

Output:

Hello, Alice!
Goodbye!

In this example, sayGoodbye is passed as a callback to greet, which calls it after greeting the user.


Callbacks in Higher-Order Functions

Frequently used javaScript array methods like .forEach, .map, and .filter leverage callbacks to define how elements should be processed. These methods demonstrate the power of callbacks in enabling concise and expressive operations.

Example: Using Callbacks in filter

const numbers = [1, 2, 3, 4, 5];
const isEven = (num) => num % 2 === 0;

const evenNumbers = numbers.filter(isEven);
console.log(evenNumbers); // [2, 4]

Here, isEven acts as a callback that determines which elements to keep in the filtered array.

How Callbacks Receive Arguments

The callback provided to .filter receives three arguments:

  1. currentValue – The current element being processed.

  2. index – The index of the current element.

  3. array – The array being traversed.

This allows callbacks to perform advanced operations using the entire dataset. I would recommend you study the MDN Docs on the inner working on Array.protype.filter if you want to write you own custom filter like I did here.


Synchronous vs. Asynchronous Callbacks

Synchronous Callbacks

Synchronous callbacks are executed immediately within the context of the calling function.

function processArray(arr, callback) {
    arr.forEach((element) => {
        callback(element);
    });
}

processArray([1, 2, 3], console.log);

Output:

1
2
3

The console.log function is invoked synchronously for each array element.

Asynchronous Callbacks

Asynchronous callbacks enable non-blocking operations, making them essential for tasks like network requests or timers.

setTimeout(() => {
    console.log("This runs after 2 seconds");
}, 2000);

Here, the callback is executed after a delay, allowing the program to continue running other code in the meantime.


The Role of .call() and .apply() in Callbacks

Both .call() and .apply() are methods that execute functions with a specified this context and arguments. They are particularly useful in callbacks when you need to control the value of this dynamically.

Let’s think about it this way:

Imagine you’re a team lead responsible for organising a sprint retrospective. The retrospective session is like a function that needs to be executed. Normally, you (the team lead) would facilitate the meeting in your usual capacity. However, there are times when you need another team member, like a scrum master, to take over as the facilitator and run the meeting on your behalf.

  • .call(): This is like asking the scrum master to lead the meeting and explicitly listing out each agenda item one by one: “Please discuss point A, point B, and point C.”

  • .apply(): This is like giving the scrum master a pre-written agenda with all the items already grouped in a single list: “Here’s the agenda for the retrospective.”

In both cases, the meeting happens, but the scrum master is now running it (representing the new this context). The difference lies in how you hand over the agenda items: individually (.call()) or as a bundled list (.apply()).

This ability to dynamically delegate the meeting (or execution) to another facilitator (context) with a specified agenda (arguments) showcases the flexibility of .call() and .apply() in controlling function behaviour.

Example: Using .call() in a Callback

function greet() {
    console.log(`Hello, ${this.name}!`);
}

const person = { name: "Alice" };

greet.call(person); // "Hello, Alice!"

The call method sets this to the person object, allowing the callback to access the correct context.

In higher-order functions, .call() can pass additional arguments or ensure the callback operates within a desired context.

Example: Using .apply() in a Callback

function greet(greeting) {
    console.log(`${greeting}, ${this.name}!`);
}

const person = { name: "Alice" };

greet.apply(person, ["Hello"]); // "Hello, Alice!"

In this example, the .apply() method is used to call the greet function with a specific context (this set to the person object) and pass an array of arguments (["Hello"]). The key difference between .apply() and .call() is that .apply() accepts an array (or array-like object) of arguments, while .call() takes a comma-separated list of arguments.


Callbacks vs. Promises and Async/Await

While callbacks remain foundational, modern JavaScript introduces Promises and async/await to simplify asynchronous programming and avoid "callback hell."

Example: From Callback to Promise to async/await

// Callback-based approach
function fetchData(callback) {
    setTimeout(() => {
        callback("Data received");
    }, 1000);
}

fetchData((data) => {
    console.log(data);
});

// Promise-based approach
function fetchDataPromise() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("Data received");
        }, 1000);
    });
}

fetchDataPromise().then((data) => console.log(data));

// async-await approach
async function fetchDataAsync() {
    const data = await new Promise((resolve) => {
        setTimeout(() => {
            resolve("Data received");
        }, 1000);
    });
    console.log(data);
}

fetchDataAsync();

Promises make the flow of asynchronous code more readable, while async/await further reduces complexity.


Best Practices for Using Callbacks

  1. Always Name Your Callbacks: Use descriptive names for clarity.

  2. Avoid Deep Nesting: Excessive callback nesting leads to difficult-to-read "callback hell." Refactor into smaller functions or use Promises.

  3. Handle Errors Gracefully: Include error-handling logic within your callbacks.

  4. Beware of this Context: Use .bind(), .call(), or arrow functions to ensure the correct this context.


Callbacks are a powerful feature in JavaScript. Whether used in synchronous operations like filter or asynchronous tasks like setTimeout, callbacks form the backbone of many JavaScript functionalities. By understanding, we can write more efficient code while laying the groundwork for understanding modern tools like Promises and async/await.

Additional reading