Breaking forEach
Understanding the limitations of the forEach method
One of the first array methods that one learns in JS is the forEach method. After all, it is a simple way of running some logic on each item in a list. But as we move on and start to create more complex code, we might get some inconsistencies in the behavior of our app or we might not realize that there is potential unpredictable functionality in our features. Let’s dive a little deeper into forEach and understand its limitations.
Before diving into limitations, let’s break forEach down. The forEach method has one mandatory parameter and one optional parameter. The mandatory parameter is a callback function and the optional is the this argument, which I will cover in another post. The callback function takes 3 parameters itself: the current element, the index and the array the forEach was called upon. This method does not return any value. The following example can give us a glimpse of how it works:
const singular = ["apple", "orange", "banana"]
const returnedValue = singular.forEach((value, index, array) => {
console.log(value, index, array);
return value;
})
console.log({returnedValue})
// apple 0 ["apple", "orange", "banana"]
// orange 1 ["apple", "orange", "banana"]
// banana 2 ["apple", "orange", "banana"]
// {returnedValue: undefined}Limitation 1: Not chainable
By now, the first limitation should be apparent. We cannot have a forEach in the middle of a method chain, only at the end.
const singular = ["apple", "orange", "banana"]
const plural = []
singular.map((value) => `${value}s`).forEach((value) => {
plural.push(value)
})
console.log(plural)
//["apples", "oranges", "bananas"]const singular = ["apple", "orange", "banana"]
const chained = singular.forEach((value) => value).map((value) => `${value}s`)
console.log(chained)
// Uncaught TypeError: Cannot read properties of undefined (reading 'map')"Limitation 2: Not asynchronous
Unlike other methods like map, forEach does not support asynchronous callbacks. This limitation is often overlooked and is particularly dangerous because it is silent. It won’t throw an error if you pass it an asynchronous callback, it will just run but with unpredictable outcomes in your code.
const singular = ["apple", "orange", "banana"]
const getIndex = (index) => new Promise((resolve) => setTimeout(() => resolve(index), Math.random() * 1000));
singular.forEach(async (item, index) => {
const i = await getIndex(index);
console.log(i);
});
// 1
// 2
// 0Limitation 3: Unpredictable with sparse arrays
The forEach method invokes the callback function only for indexes in the array that have explicitly assigned values (including null and undefined).
const cinemaClients = ["George", "John", "Pete", "Matt"]
const sparseReservedSeats = [1, 2, , 4]
const reservedSeats = [1, 2, undefined, 4]
function getSeatsByUser (array){
const seatsByUser = {}
array.forEach((value, index) => {
const client = cinemaClients[index]
if(value === undefined){
seatsByUser[client] = 0
}else{
seatsByUser[client] = value
}
})
return seatsByUser
}
console.log(getSeatsByUser(sparseReservedSeats))
// { George: 1, John: 2, Matt: 4 }
console.log(getSeatsByUser(reservedSeats))
// { George: 1, John: 2, Pete: 0, Matt: 4 }Limitation 4: Only works with array-like objects
forEach is a generic method. This means that it can be applied to any object that has integer keys and a length property of type integer. But you may think “I have seen the forEach method being applied on Sets and Maps and they don’t have integer keys or a length property”. You are partly right. We can apply forEach to Maps and Sets, but those forEach methods are not borrowed from the Array prototype. Sets and Maps have their own built-in forEach methods that behave differently.
const arrayLikeObj = {
length: 3,
0: "Hello, ",
1: "my friend, ",
2: "hello."
}
const obj = {
0: "Goodbye, ",
1: "my friend, ",
2: "goodbye."
}
function getGreeting(obj){
let greeting = ""
Array.prototype.forEach.call(obj, ((value) => {
greeting += value
}))
return greeting
}
console.log(getGreeting(arrayLikeObj))
// 'Hello, my friend, hello.'
console.log(getGreeting(obj))
// ''Limitation 5: No way to stop it
Unlike for loops that can skip iterations or stop loops and other methods like every, some, find and findIndex that stop the loop once they have met a certain condition, the forEach method does not have a natural way of stopping loops. The only way to stop the loop is by throwing an error.
Looking under the hood
Now that we understand how the forEach method works and have seen its limitations, let’s take one final look under the hood to see why these limitations exist in the first place. We could write our own simplified version of forEach like this:
Array.prototype.myForEach = function(callback, thisArg) {
if (typeof callback !== 'function') {
throw new TypeError('Callback is not a function');
}
// Bind 'thisArg' if it's provided
if (thisArg) {
callback = callback.bind(thisArg);
}
const obj = Object(this);
// Limitation 4: We need to have a length property
const objLength = obj.length;
for (let i = 0; i < objLength; i++) {
// Limitation 3: We only work with explicitly assigned values
if (obj.hasOwnProperty(i)) {
callback(obj[i], i, obj);
}
}
// Limitation 1: Always return undefined
return undefined;
}Conclusion
While you can solve most problems using different types of loops, choosing the right one is about more than just personal preference, it’s about using the right tool for the job.
You should probably avoid forEach if:
you need the loop to return a value
you need to work with asynchronous operations
you are iterating over sparse arrays
you are dealing with objects that lack a length property or don't use integer keys
you need a way to break early or exit the loop
Hopefully, by understanding how forEach works behind the scenes, you can feel more confident and intentional about which loop you choose in the future.
