Previous Article
var vs let vs const in JavaScript: Every Difference That Matters
24 April 2023
map(), filter() and reduce() are the three array methods that make JavaScript feel functional. Each takes a callback, each returns something new, and none of them modifies the original array.
The textbook examples — doubling numbers, filtering evens — show the syntax but not why these methods matter. This guide uses realistic data and covers the parts that actually trip people up.
const orders = [
{ id: 1, customer: 'Ada', total: 120.00, status: 'paid', items: 3 },
{ id: 2, customer: 'Grace', total: 45.50, status: 'pending', items: 1 },
{ id: 3, customer: 'Ada', total: 220.75, status: 'paid', items: 5 },
{ id: 4, customer: 'Alan', total: 15.00, status: 'refunded', items: 1 },
{ id: 5, customer: 'Grace', total: 310.20, status: 'paid', items: 8 },
];
map() returns a new array of the same length, with each element replaced by whatever the callback returns.
const summaries = orders.map(order => `#${order.id} — ${order.customer}: £${order.total.toFixed(2)}`);
// ["#1 — Ada: £120.00", "#2 — Grace: £45.50", …]
// Reshaping objects — the most common real use
const rows = orders.map(({ id, customer, total }) => ({ id, customer, total }));
Note the parentheses around ({ id, customer, total }). Without them, JavaScript reads the braces as a function body rather than an object literal and you get undefined for every element. This catches everyone at least once.
// Wrong — builds an array of undefined and throws it away orders.map(order => console.log(order.id)); // Right orders.forEach(order => console.log(order.id));
If you are not using the returned array, you wanted forEach. Using map allocates a whole array for nothing and misleads the next reader into looking for a result that does not exist.
filter() returns a new array containing only elements for which the callback returned something truthy. Length is zero to original length.
const paid = orders.filter(order => order.status === 'paid'); // 3 orders const large = orders.filter(order => order.total > 100); // 3 orders const adasPaid = orders.filter(o => o.customer === 'Ada' && o.status === 'paid');
A useful idiom — Boolean as the callback removes every falsy value:
const values = ['a', '', null, 'b', undefined, 0, 'c']; const clean = values.filter(Boolean); // ["a", "b", "c"]
Careful with that one: it also strips 0 and '', which may be legitimate data. For arrays of numbers use an explicit predicate.
// Looking for one thing? filter gives you an array you then have to unwrap. const order = orders.filter(o => o.id === 3)[0]; // works, but wasteful // find returns the element itself, and stops at the first match. const order = orders.find(o => o.id === 3);
find() short-circuits; filter() always walks the entire array. On large collections that difference is real.
reduce() is the general-purpose one. It walks the array carrying an accumulator, and returns whatever the accumulator holds at the end. That result can be a number, a string, an object or another array.
const revenue = orders .filter(o => o.status === 'paid') .reduce((sum, order) => sum + order.total, 0); // 650.95
The second argument to reduce — 0 here — is the initial accumulator value.
This is the reduce bug that reaches production:
[].reduce((a, b) => a + b); // TypeError: Reduce of empty array with no initial value [].reduce((a, b) => a + b, 0); // 0 — correct
Without an initial value, reduce uses the first element as the accumulator and starts from the second. On an empty array there is no first element, so it throws. Since arrays that are usually populated are occasionally empty, omitting the initial value means a crash that only appears in the edge case.
It also changes the types involved. Building an object without an initial {} makes the first element your accumulator, which is almost never what you meant.
Grouping is where reduce earns its place:
const byCustomer = orders.reduce((groups, order) => {
(groups[order.customer] ??= []).push(order);
return groups;
}, {});
// { Ada: [order1, order3], Grace: [order2, order5], Alan: [order4] }
Counting:
const statusCounts = orders.reduce((counts, order) => {
counts[order.status] = (counts[order.status] ?? 0) + 1;
return counts;
}, {});
// { paid: 3, pending: 1, refunded: 1 }
Building a lookup index, which turns repeated find() calls into constant-time access:
const byId = orders.reduce((index, order) => {
index[order.id] = order;
return index;
}, {});
byId[3].customer; // "Ada" — no scanning
Computing several things in one pass:
const stats = orders.reduce((acc, order) => ({
count: acc.count + 1,
total: acc.total + order.total,
items: acc.items + order.items,
max: Math.max(acc.max, order.total),
}), { count: 0, total: 0, items: 0, max: -Infinity });
Grouping is common enough that JavaScript now has it built in:
const byStatus = Object.groupBy(orders, order => order.status);
// { paid: [...], pending: [...], refunded: [...] }
Available in current browsers and Node 21+. Clearer than the reduce equivalent when you can rely on it.
Because each method returns a new array, they compose:
const topPaidCustomers = orders
.filter(order => order.status === 'paid')
.map(order => ({ customer: order.customer, total: order.total }))
.sort((a, b) => b.total - a.total)
.slice(0, 3);
Filter before you map. Mapping first transforms elements you are about to discard.
One warning: sort() mutates the array it is called on. In the chain above that is safe because map() already produced a fresh array, but calling orders.sort(…) directly would reorder your original data. Use toSorted() where available, or copy first with [...orders].sort(…).
All three callbacks receive (element, index, array):
const numbered = orders.map((order, index) => `${index + 1}. ${order.customer}`);
This causes a classic bug when combined with functions that take their own second parameter:
['1', '2', '3'].map(parseInt); // [1, NaN, NaN] — not what you wanted ['1', '2', '3'].map(Number); // [1, 2, 3] ['1', '2', '3'].map(s => parseInt(s, 10)); // [1, 2, 3]
map passes the index as parseInt's second argument, the radix — so it parses "2" in base 1 and "3" in base 2, both invalid.
Map and flatten one level in a single pass:
const allItems = orders.flatMap(order => order.lineItems); // Also useful for filter-and-map together: return [] to drop an element. const paidTotals = orders.flatMap(o => o.status === 'paid' ? [o.total] : []);
These methods always process every element. A plain loop can stop early:
// Walks all million elements
const found = hugeArray.filter(x => x.id === target)[0];
// Stops at the match
const found = hugeArray.find(x => x.id === target);
// Need to break on a condition mid-iteration? Use a loop.
for (const item of hugeArray) {
if (item.corrupt) break;
process(item);
}
Also prefer a loop when the operation is genuinely imperative — writing to a database, awaiting each result in sequence. await inside map does not do what people expect; it returns an array of promises, which you then have to hand to Promise.all().
// Runs in parallel — usually what you want
const results = await Promise.all(orders.map(o => fetchDetails(o.id)));
// Runs one at a time — when you need sequencing or rate limiting
for (const order of orders) {
await fetchDetails(order.id);
}
| Method | Returns | Length | Stops early |
|---|---|---|---|
map | New array | Same | No |
filter | New array | 0 to same | No |
reduce | Any value | — | No |
find | Element or undefined | — | Yes |
some / every | Boolean | — | Yes |
forEach | undefined | — | No |
None of these mutate the array they are called on — unlike sort, reverse, splice and push, which do.
Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.