Why your crush and JavaScript does not callback anymore
Nobody wants their relationship or JavaScript to look like hell, not a relationship advisor though but can talk about JavaScript for an hour or so. Legend says, crush never callback; there is no more to it and I am not a legend so let us focus on something which can be answered, JavaScript callbacks their heaven and hell.
You can understand JavaScript callbacks from a day-to-day conversation “Hey, I will callback, once I finish my work”. JavScript’s callbacks are no different, a code that you wanna run once a primary work is finished. There are 2 bodies (functions) involved in a callback scenario. We can term them as called and a callback function. The called function knows when to call the callback, while the callback function knows what to execute.
How to callback?
Let us take a super complex math problem to understand this scenario better, which is finding whether the given number is even or odd. The below logic will find even or odd:
// super complex math problem
if (number % 2 === 0) {
// Yay! it's even
} else {
// Meh! it's odd
}
Enclosing above computation inside a handy function named “isEven”:
const isEven = (number) => {
// super complex math problem
if (number % 2 === 0) {
// Yay! it's even
} else {
// Meh! it's odd
}
};
The isEven function does its job but once done don’t know where to head from there with the computed data. We will provide isEven a function which it can call once completed the task:
const isEven = (number, callback) => {
// super complex math problem
if (number % 2 === 0) {
callback(true); // Yay! it's even
} else {
callback(false); // Meh! it's odd
}
};
Now let us create isEven callback function which we can pass as parameter when isEven is called.
const isEven = (number, callback) => {
// super complex math problem
if (number % 2 === 0) {
callback(true);
} else {
callback(false);
}
};const isEvenCallback = function (result) {
if (result) {
console.log("Yay! it's even");
} else {
console.log("Meh! it's odd");
}
};isEven(10, isEvenCallback);
Life is good but it can be better with some code reduction. We have created isEvenCallback function which will be called after the is even check by isEven method. But this isEvenCallback is solely meant for passing as a callback to isEven otherwise it has no purpose. So we can simply make an anonymous callback function at the parameter declaration only.
isEven(10, function (result) {
if (result) {
console.log("Yay! it's even");
} else {
console.log("Meh! it's odd");
}
});
Life is better but it can be awesome with arrow functions. If we have no use of this inside any function we can convert it to arrow function to make syntax handy.
isEven(10, result => {
if (result) {
console.log("Yay! it's even");
} else {
console.log("Meh! it's odd");
}
});
With the power of ternary will make it one liner:
isEven(10, result => result ? console.log("Yay! it's even") : console.log("Meh! it's odd"));
So this was some pretty nice but not worthy example of a callback function that helps you to understand the callback syntax. But they actually meant for some big heroic stuff named async.
The above task was not asynchronous, but suppose if the above task was computation heavy and takes an ample amount of time, also the time when it completes is anonymous then the callback is a need that can’t be avoided. The benefit which callback provides is that we simply need to specify what is to be done when a task is completed; now when it is completed is totally dependent on the execution of the called function.
Most general callback format
Let us see one more such example with some real computation (Lol, seriously). Finding factorial of a given number. We have a factorial finder code, we will stuff it with our callback logic, so when it’s done with finding the factorial it will call the callback function:
const factorial = (number, callback) => {
if (typeof number === 'number') {
let fact = 1;
for (let i = 1; i <= number; i++) {
fact *= i;
}
callback(null, fact);
} else {
callback('The number provided must be of type number', null);
}
};
Here our callback receives 2 parameters, first one is error while the second one is the computed result. This signature can be defined by us, it is not something fixed and has to be the same, but most of the callback function follows this pattern having error then result parameters. So either the case one will be null which will tell us what actually happened when the computation is happening.
Calling the factorial function and implementing the callback:
factorial(5, (error, result) => {
if (error) {
console.log('Error Occurred:', error);
return;
}
console.log('Factorial:', result);
});
// Factorial:120
So we have seen the general format for most of the callback functions you find mainly in NodeJS environments.
Callbacks are everywhere
They are literally everywhere, in intervals, timeout, array functions, event listeners, fetch API, crush or interviewer callbacks, etc. We will check them out one by one except the last 2. In such cases, we don’t have to create the called function instead we only focus on the callback code which will be executed by the called function.
Interval callback
let counter = 0;
const interval = setInterval(() => {
(++counter) <= 5 ?
console.log(`Callback on interval ${counter}`) :
clearInterval(interval);
}, 1000);
The setInterval takes 2 parameters, a callback function and time in milliseconds after which the interval will be called repeatedly. We can write our logic inside the callback function which will be executed again and again after the given milliseconds.
Timeout callback
let counter = 0;
setTimeout(() => {
console.log('Callback when timeout');
}, 6000);
Similar to setInterval the setTimeout takes 2 parameters, a callback function and time in milliseconds after which the timeout will be called. We can write our logic inside the callback function which will be executed after the given milliseconds.
Event listener callback
const button = document.querySelector('button');
document.querySelector('button').addEventListener('click', function () {
console.log('Button is clicked');
});
The JavaScript event listener also takes a callback function which will be called when the event occurs. In the above example an anonymous function will be called when the button click occurs.
Fetch API callback
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => console.log(json));
Many of the JavaScript API takes callbacks and executes the provided piece of code. Above the 2 then methods are executing a provided callback function when the fetch API brings the data.
Array methods callback
const db = {
users: [
{ id: 1, name: 'Allen' },
{ id: 2, name: 'John' },
{ id: 3, name: 'Martin' },
],
posts: [
{ id: 101, userId: 1, title: 'Nisi sint cillum officia laborum consequat labore.' },
{ id: 102, userId: 1, title: 'Ipsum ea fugiat velit do.' },
{ id: 103, userId: 2, title: 'Qui ullamco veniam non sit mollit.' },
{ id: 104, userId: 3, title: 'Laboris qui officia anim proident Lorem esse aliquip.' },
],
comments: [
{ id: 11, postId: 101, userId: 2, comment: 'Nulla tempor nisi dolor velit id qui culpa et tempor eiusmod sint.' },
{ id: 12, postId: 101, userId: 1, comment: 'Reprehenderit est cupidatat magna eu anim.' },
{ id: 13, postId: 102, userId: 3, comment: 'Sint adipisicing sint ad cillum ipsum aute voluptate ea fugiat nostrud ut.' },
{ id: 14, postId: 103, userId: 1, comment: 'Mollit id ipsum sunt laborum duis.' },
{ id: 15, postId: 101, userId: 3, comment: 'Exercitation ipsum pariatur sit Lorem deserunt occaecat.' },
]
};
Consider the above db object which holds different entity data. We can make use of array functions to operate on these data.
// array functions
const found = db.users.find(i => i.id === 1);
const filtered = db.posts.filter(i => i.userId === 1);
const mapped = db.posts.map(i => ({ id: i.id, title: i.title }));console.log('found:', found);
console.log('filtered:', filtered);
console.log('mapped:', mapped);
Here we are using different array methods to get the desired data. For more understanding about array methods you can check out the CodeOmelet post JavaScript array methods for living. All of these array methods take a callback method which is considered as predicates and executed based on our provided logic.
Async operation with callbacks
Not all tasks are synchronous, many of them take time to execute and uncertain about the time it takes to complete the execution. Obtaining data from a database, calling a third-party API, reading data from a file, waiting for a big algorithm to complete is an example of such tasks. All of these will break synchronous code logic as the data obtained from these will not be obtained on the immediate line instead will be available after a certain time. To handle such situations callbacks are created.
Let us create an artificial asynchronous task and handle it using a callback. We will make use of the setTimeout function based on which our task will take time to execute but once executed will provide the result and we will perform our operation after that.
For this consider the previously used db object acting as a database for us and a function named fetchUserById which will fetch users from the db but takes time to execute.
const fetchUserById = (id, callback) => {
setTimeout(() => {
const results = db.users.find(i => i.id === id);
results ?
callback(null, results) :
callback('Not found', null);
}, 2000);
};
Here the fetchUserById will provide the user based on the id. The function will complete after 2 seconds and later calls the callback function, either providing error or result.
fetchUserById(1, (err, user) => {
if (err) {
console.log('Error:', err);
return;
}
console.log('user:', user);
});
The callback function will have 2 parameters, the error and user, incase user id not found will receive error else user data.
What is callback hell?
Every powerful solution has some side effects too, and callback hell is a side effect referred to as hell. How come such a great feature can become hell for the programmers, let us find it out, consider the previous db object which has interdependent data, like users have posts and posts have comments. Suppose we have a requirement to obtain a user object with all of their posts and each post must contain their comments too. We have created different db methods to obtain these data independently each of which are async.
const fetchUserById = (id, callback) => {
setTimeout(() => {
const results = db.users.find(i => i.id === id);
results ?
callback(null, results) :
callback('Not found', null);
}, 2000);
};const fetchPostsByUserId = (userId, callback) => {
setTimeout(() => {
const results = db.posts.filter(i => i.userId === userId);
results.length ?
callback(null, results) :
callback('Not found', null);
}, 2000);
};const fetchCommentsByPostId = (postId, callback) => {
setTimeout(() => {
const results = db.comments.filter(i => i.postId === postId);
results.length ?
callback(null, results) :
callback('Not found', null);
}, 2000);
};
We have to form a chain of method calls one after another to obtain the final result, for example, we first fetch the user then based on the user will obtain all the posts written by the user, and based on each post will fetch all the comments per post.
fetchUserById(1, (err, user) => {
if (err) {
console.log('Error:', err);
return;
}fetchPostsByUserId(user.id, (err, posts) => {
if (err) {
console.log('Error:', err);
return;
}
user.posts = posts.map(i => ({ id: i.id, title: i.title, comments: [] }));user.posts.forEach(post => {
fetchCommentsByPostId(post.id, (err, comments) => {
if (err) {
console.log('Error:', err);
return;
}
post.comments = comments.map(i => ({ id: i.id, title: i.title }));
console.log('user:', user);
})
});
});
});
The data is obtained when the posts forEach loop ends which calls the comments method depending on post id. In case when we need to continue our code from this point we need to know when the loop ends and all the comments call completes. Such problems are the major drawback while working with callbacks and these nested callbacks are called callback hells or pyramids of doom.
If we are not considering any solution out of callbacks then the way to deal with callbacks is callback itself. In order to tackle the above situation, we have to know that when all of the post’s comments are obtained once done we get the final required user object. We will create a postCount variable which will hold the value for the number of times the fetch comments function is called, if the post count is the same as posts length then it will call our callback which will have the further code logic written which we want to execute further.
const userLoaded = (user) => {
// came from callback hell
console.log('user:', user);
};fetchUserById(1, (err, user) => {
if (err) {
console.log('Error:', err);
return;
}fetchPostsByUserId(user.id, (err, posts) => {
if (err) {
console.log('Error:', err);
return;
}
user.posts = posts.map(i => ({ id: i.id, title: i.title, comments: [] }));if (!user.posts.length) {
userLoaded(user);
return;
}let postCount = 1;
user.posts.forEach(post => {
fetchCommentsByPostId(post.id, (err, comments) => {
if (err) {
console.log('Error:', err);
return;
}
post.comments = comments.map(i => ({ id: i.id, title: i.title }));if (postCount === user.posts.length) {
userLoaded(user);
} else {
postCount++;
}
})
});
});
});
We have added a check before calling the fetch comment function, which checks whether the user has posts or not, if not then call the user loaded function which will take the code execution further.
// user have not posts
if (!user.posts.length) {
userLoaded(user);
return;
}
And when users have posts then will iterate each post and call fetch comment function, but this time we will have a postCount variable which will be incremented not on iteration but when a successful fetch comment function execution. If the post count is the same as posts length then it will call the user loaded callback function which will have the entire user object otherwise increments the post count variable.
let postCount = 1;
user.posts.forEach(post => {
fetchCommentsByPostId(post.id, (err, comments) => {
if (err) {
console.log('Error:', err);
return;
}
post.comments = comments.map(i => ({ id: i.id, title: i.title }));if (postCount === user.posts.length) {
userLoaded(user);
} else {
postCount++;
}
})
});
In the user loaded function we are simply printing the obtained user object and our code execution will then continue from the user loaded function.
user loaded function.
const userLoaded = (user) => {
// came from callback hell
console.log('user:', user);
};
That’s how you deal with callback hell with the help of another callback. This callback pattern is the worst to manage between developers and the control flow is something that needs to be taken care of. Most of the practical scenarios are more confusing than the above one which we have seen and callback helps to complicate it even further. If we are keen to find a solution outside the callback spectrum, a shining solution is waiting to Promise us to get rid of callback tantrums.
Life was not so good with callbacks before ES6 (ES2015), after which we have got JavaScript’s Promise. Not talking about the promises couples do but JavaScript’s promise object which will help you to get rid of callback’s pyramid of doom. We will check about Promises in near future articles and compare them with callbacks side by side.
Git Repository
Check out the git repository for this project or download the code.
Summary
This article majorly focused on understanding the concept of callback and their problems. For intermediate or advanced level devs these concepts are already gulped but for some beginners out there callback is something, not a straightforward topic. This was my take on callback and I tried to address the pros and hells of callback which I personally feel I have faced when I first met callbacks.
Hope this article helps.
Originally published at https://codeomelet.com.