JavaScript (ES2026) Module 6 – Arrays and array methods
reduce in depth
Use JavaScript reduce to sum, group, index and flatten arrays, always with an initial value, avoid the quadratic spread trap, and know when a loop reads better.
What you will learn
- Use reduce to sum, group, index and flatten data
- Pass an initial value every time, and explain what goes wrong without one
- Recognise when reduce hurts readability, and choose a clearer method
Before you start
What you’ll learn · Before you start
What you will learn
- Use reduce to sum, group, index and flatten data
- Pass an initial value every time, and explain what goes wrong without one
- Recognise when reduce hurts readability, and choose a clearer method
Before you start
On this page
reduce() turns a whole array into one value by carrying an accumulator from item to item. You give it a
function and a starting value: spends.reduce((sum, amount) => sum + amount, 0) calls the function once for each
item with the accumulator so far and the next item, whatever the function returns becomes the accumulator of the
next call, and the value of the last call is the result. The accumulator can be a number for a sum, an object or a
Map for totals per category or an index by id, or an array for a flattened list. Always pass the starting value:
without it, reduce() starts from the first item, gives wrong answers on arrays of objects and throws a TypeError
on an empty array. This lesson builds each kind of accumulator, measures the spread pattern that makes reduce()
quadratic, and shows when Object.groupBy() or a plain loop reads better.
How reduce() runs
// reduce() carries one value, the accumulator, from item to item. Each call gets the accumulator and the next item
// and returns the new accumulator; what the last call returns is the result.
const spends = [450, 1200, 300];
const total = spends.reduce((sum, amount, index) => {
const next = sum + amount;
console.log(`call ${index + 1}: sum ${String(sum).padStart(4)} + amount ${String(amount).padStart(4)} = ${next}`);
return next;
}, 0);
console.log('total:', total); Output
call 1: sum 0 + amount 450 = 450 call 2: sum 450 + amount 1200 = 1650 call 3: sum 1650 + amount 300 = 1950 total: 1950
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node reduce-steps.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The second argument, 0, is the initial value: it is the accumulator of the first call. That call adds the first
amount and returns 450, which becomes sum in the second call, and so on. When no items are left, reduce() returns
whatever the last call returned, 1950. The function receives four arguments: the accumulator, the item, its index
(the trace prints it) and the array. Like map() and filter(), reduce() does not change the array.
The accumulator passed from call to call
Text description of the diagram
The diagram is a column of boxes joined by arrows labelled "accumulator".
- It starts with the initial value, 0.
- Call 1 adds the first spend, 450, to 0 and returns 450.
- Call 2 adds 1200 to 450 and returns 1650.
- Call 3 adds 300 to 1650 and returns 1950.
With no items left, reduce() returns the value of the last call, 1950. Every call has to return the accumulator: whatever it returns is what the next call receives.
The one rule inside the function: return the accumulator, every time. Whatever the function returns is all the
next call gets. An arrow function with a block body, (acc, n) => { acc.push(n); }, returns undefined, and the
next call then fails on undefined.push.
Always pass an initial value
The initial value is optional, and leaving it out causes a whole family of reduce() bugs. Without it, the first
item becomes the starting accumulator and the function is first called with the second item:
// Without an initial value, reduce() starts with the first item as the accumulator. That goes wrong in three ways.
const expenses = [
{ category: 'food', amountPaise: 45000 },
{ category: 'travel', amountPaise: 120000 },
];
// 1. The first item is an object, so the first "sum" is an object plus a number.
console.log('no initial value:', expenses.reduce((sum, e) => sum + e.amountPaise));
console.log('initial value 0: ', expenses.reduce((sum, e) => sum + e.amountPaise, 0));
// 2. With one item, reduce() returns that item without calling the function at all.
console.log('one item:', [{ category: 'rent', amountPaise: 900000 }].reduce((sum, e) => sum + e.amountPaise));
// 3. With no items, there is nothing to start from: a TypeError.
try {
[].reduce((sum, e) => sum + e.amountPaise);
} catch (error) {
console.log('no items:', error.name, '-', error.message);
}
console.log('no items, initial value 0:', [].reduce((sum, e) => sum + e.amountPaise, 0)); Output
no initial value: [object Object]120000
initial value 0: 165000
one item: { category: 'rent', amountPaise: 900000 }
no items: TypeError - Reduce of empty array with no initial value
no items, initial value 0: 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node initial-value.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
In the Run button’s engine, QuickJS 0.32.0, the message is empty array. The language fixes only the type of the
error, a TypeError; each engine writes its own message.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
no initial value: [object Object]120000
initial value 0: 165000
one item: { category: 'rent', amountPaise: 900000 }
no items: TypeError - empty array
no items, initial value 0: 0
- Wrong types. The first accumulator is the first expense, an object, so the first “sum” is an object plus a
number. JavaScript turns the object into the text
[object Object]and joins the number to it: no error, just a string where a total should be. With0as the initial value, the sum is 165000. - One item. With a single item and no initial value,
reduce()returns that item without calling the function at all, so a list with one expense “totals” to the expense object itself. - No items. With neither items nor an initial value there is nothing to start from, and the specification
requires a
TypeError. A month with no expenses crashes the report; with an initial value it totals to 0.
Pass the initial value every time, of the same type as the result: 0 for a sum, new Map() or {} for totals and
indexes, [] for a list. Then every one of the three cases above gives the answer you meant.
Totals per category: an object accumulator, or Object.groupBy
// A trip's expenses in paise: the total per category with reduce(), and the same with Object.groupBy().
const expenses = [
{ category: 'food', amountPaise: 45000 },
{ category: 'travel', amountPaise: 120000 },
{ category: 'food', amountPaise: 30000 },
{ category: 'stay', amountPaise: 250000 },
{ category: 'travel', amountPaise: 8000 },
];
const totals = expenses.reduce((acc, e) => {
acc[e.category] = (acc[e.category] ?? 0) + e.amountPaise;
return acc;
}, {});
console.log('reduce:', totals);
const groups = Object.groupBy(expenses, (e) => e.category);
console.log('groupBy:', Object.keys(groups), '| food has', groups.food.length, 'items');
const fromGroups = {};
for (const [category, items] of Object.entries(groups)) {
fromGroups[category] = items.reduce((sum, e) => sum + e.amountPaise, 0);
}
console.log('groupBy, then one sum per group:', fromGroups);
console.log('food in rupees:', (totals.food / 100).toFixed(2)); Output
reduce: { food: 75000, travel: 128000, stay: 250000 }
groupBy: [ 'food', 'travel', 'stay' ] | food has 2 items
groupBy, then one sum per group: { food: 75000, travel: 128000, stay: 250000 }
food in rupees: 750.00
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node expense-totals.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The reduce() version starts from an empty object and, for each expense, adds its amount to the total of its
category: acc[e.category] ?? 0 gives 0 the first time a category is seen. The function changes the accumulator and
returns it. That is safe here: the object was created for this one reduce() call, so no other code can see it
change. A {} accumulator has a different weakness, its inherited keys, which the next section shows.
Object.groupBy(), added in ECMAScript 2024, does the grouping part for you: it returns an object whose keys are the
categories and whose values are arrays of the matching expenses. Totals then take one small reduce() per group.
Which is better depends on what you need. When you want the groups themselves, to list the food expenses or count
them, Object.groupBy() says so in one call. When you want only the totals, a single reduce() gets there in one
pass without keeping every group in memory. The object that Object.groupBy() returns has no prototype, so a
category named toString cannot clash with anything; the grouping lesson of this module covers it, and Map.groupBy(),
in depth.
Version note
Object.groupBy() and Map.groupBy() need Node.js 21 or later, Chrome 117, Firefox 119 or Safari 17.4.
An index by id, and counting with a Map
A Map is often the best accumulator: it accepts any key, and its set() method returns the Map itself, so one
expression can update the accumulator and return it:
// Index users by id once, then look each one up without searching the array.
const users = [
{ id: 'u17', name: 'Asha' },
{ id: 'u42', name: 'Ravi' },
{ id: 'u08', name: 'Meena' },
];
const byId = users.reduce((map, user) => map.set(user.id, user), new Map());
console.log(byId.get('u42').name, byId.size, byId.has('u99'));
const sameWithoutReduce = new Map(users.map((user) => [user.id, user]));
console.log(sameWithoutReduce.get('u08').name);
// Counting with a Map accumulator: any key is safe, 'toString' included.
const tags = ['news', 'toString', 'news', 'sport'];
const counts = tags.reduce((acc, tag) => acc.set(tag, (acc.get(tag) ?? 0) + 1), new Map());
console.log(counts);
// The same count on a {} accumulator finds an inherited method under 'toString'.
const unsafe = tags.reduce((acc, tag) => {
acc[tag] = (acc[tag] ?? 0) + 1;
return acc;
}, {});
console.log("with {}, the count for 'toString' is a", typeof unsafe.toString); Output
Ravi 3 false
Meena
Map(3) { 'news' => 2, 'toString' => 1, 'sport' => 1 }
with {}, the count for 'toString' is a string
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node index-by-id.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
users.reduce((map, user) => map.set(user.id, user), new Map()) builds the index in one pass, and from then on
byId.get('u42') finds a user without searching the array. The same Map can be built without reduce(), by
turning each user into a [key, value] pair and passing the pairs to new Map(), which says more directly what it
builds.
The tag count shows why a plain object is a risky accumulator for keys that come from data. {} inherits properties
from Object.prototype, among them toString and constructor. For the tag 'toString', acc[tag] ?? 0 finds the
inherited method instead of undefined, adds 1 to a function and stores a string. A Map has no such keys, and neither
does an object made with Object.create(null) or returned by Object.groupBy().
Other shapes: flatten, maximum, running totals, pipelines
// The accumulator can be any value: an array to flatten into, a running maximum, a list of balances, or a value
// passed through a list of functions.
const baskets = [['rice', 'dal'], ['tea'], ['salt', 'oil']];
console.log('flatten:', baskets.reduce((all, basket) => all.concat(basket), []));
console.log('flat(): ', baskets.flat());
const readings = [31, 29, 35, 33];
console.log('largest:', readings.reduce((max, n) => (n > max ? n : max), -Infinity));
const changes = [500, -200, -100, 1000];
const balances = changes.reduce((acc, change) => {
acc.push((acc.at(-1) ?? 0) + change);
return acc;
}, []);
console.log('balance after each change:', balances);
const steps = [(x) => x + 1, (x) => x * 2];
console.log('reduce:', steps.reduce((value, step) => step(value), 5), '| reduceRight:', steps.reduceRight((value, step) => step(value), 5)); Output
flatten: [ 'rice', 'dal', 'tea', 'salt', 'oil' ] flat(): [ 'rice', 'dal', 'tea', 'salt', 'oil' ] largest: 35 balance after each change: [ 500, 300, 200, 1200 ] reduce: 12 | reduceRight: 11
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node more-shapes.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
- Flatten:
concat()joins each basket onto the list so far.flat()does the same in one call and reads better, so use it; the module’s lesson onflat()andArray.from()covers it. - Maximum: a running maximum with
-Infinityas the initial value, which every number beats. UnlikeMath.max(...readings), it works on arrays of any size, because spreading a very large array into one call can hit the engine’s limit on arguments. - Running totals: the accumulator is an array of balances, and each call adds the previous balance,
acc.at(-1) ?? 0, to the next change. - Pipelines: the accumulator is a value passed through a list of functions.
reduceRight()works the same way from the last item to the first, so the two directions give different results: (5 + 1) × 2 is 12, while 5 × 2 + 1 is 11.
The spread trap: a reduce() that slows down quadratically
One way to build an object with reduce() copies the accumulator on every call, so that nothing is ever changed in
place: rows.reduce((acc, row) => ({ ...acc, [row.id]: row }), {}). It looks tidy and gives the right answer, and on
a large array it is very slow:
// Indexing rows by id with reduce(): copying the accumulator with spread on every row, against adding to one object.
// Times are in milliseconds; the one-object version is timed three times and the fastest run is shown.
const makeRows = (n) => Array.from({ length: n }, (_, i) => ({ id: `u${i}`, name: `User ${i}` }));
const withSpread = (rows) => rows.reduce((acc, row) => ({ ...acc, [row.id]: row }), {});
const withOneObject = (rows) =>
rows.reduce((acc, row) => {
acc[row.id] = row;
return acc;
}, {});
function time(task, rows, runs) {
let best = Infinity;
for (let run = 0; run < runs; run++) {
const start = performance.now();
const index = task(rows);
best = Math.min(best, performance.now() - start);
if (Object.keys(index).length !== rows.length) throw new Error('a row is missing');
}
return best;
}
const fmt = (n) => n.toLocaleString('en-US');
console.log(' rows spread copy one object properties the spread copied');
for (const n of [250, 500, 1000, 2000]) {
const rows = makeRows(n);
const spread = time(withSpread, rows, 1).toFixed(1).padStart(9);
const one = time(withOneObject, rows, 3).toFixed(2).padStart(8);
console.log(`${fmt(n).padStart(6)} ${spread} ms ${one} ms ${fmt((n * (n - 1)) / 2).padStart(12)}`);
} Output
rows spread copy one object properties the spread copied 250 3.1 ms 0.04 ms 31,125 500 13.0 ms 0.04 ms 124,750 1,000 155.8 ms 0.07 ms 499,500 2,000 1195.1 ms 0.14 ms 1,999,000
This output changes from run to run: the times depend on the computer and on what else it is running
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node spread-accumulator.mjs
The spread on row k copies the k − 1 properties already in the accumulator into a new object, so the copies add up to n × (n − 1) ÷ 2: 499,500 for 1,000 rows and 1,999,000 for 2,000. The work grows with the square of the number of rows, and the measured time grew faster still: in the run above, each doubling of the rows made the spread version several times slower, and 2,000 rows took over a second, while adding each row to one object stayed under a millisecond at every size.
The fix is the one the totals example already uses: change the accumulator you created as the initial value, and
return it. Nothing outside the reduce() call can see that object until it is finished, so changing it in place has
none of the risks of changing data that other code holds. For an index, new Map(rows.map((row) => [row.id, row]))
and Object.fromEntries(rows.map((row) => [row.id, row])) are one pass each and say what they build.
When reduce() hurts readability
reduce() can do the work of almost every other array method, and that is the trouble: a reader has to work out
what the accumulator is before they know what the code does. Prefer the method that names the job:
| The job | With reduce() | Clearer |
|---|---|---|
| Add up numbers | reduce((s, n) => s + n, 0) |
reduce() is fine here |
| Group items | reduce() into an object of arrays |
Object.groupBy(), Map.groupBy() |
| Index by a key | reduce() into a Map |
new Map(list.map(…)) |
| Flatten one level | reduce() with concat() |
flat() |
| Yes-or-no questions | reduce() into a boolean |
some(), every() |
| Several results at once | an accumulator object with many fields | a for…of loop with named variables |
A reduce() whose function runs past a few lines, keeps several values in one accumulator, or contains another
reduce(), is usually clearer as a for…of loop with a well-named variable or two. The loop does the same work in
one pass, and a reader can follow it line by line.
Exercises
Exercise · Easy · JavaScript
Total a month's expenses per category in a Map
Write totalByCategory(expenses) in totals.mjs. Each expense is an object such as { category: 'food', amountPaise: 45000 }, and the function returns a Map from each category to the total of its amounts in paise, with the categories in the order they first appear.
totalByCategory([{ category: 'food', amountPaise: 45000 }, { category: 'food', amountPaise: 30000 }])returnsnew Map([['food', 75000]]).- An expense whose
categoryis missing or an empty string counts as'other'. - Any category name works,
'constructor'and'toString'included. amountPaisemust be a whole number: anything else, such as99.5or'500', throws aTypeError.- An empty list gives an empty Map, and the expenses passed in must not change.
The starter works for clean data but has no 'other' category and accepts any amount. Run the sample tests when you are done.
Starter code · totals.mjs
// The expense totals of lesson javascript/reduce-in-depth.
export function totalByCategory(expenses) {
// Fine for clean data: there is no 'other' category, and any amount is added as it is.
return expenses.reduce((totals, expense) => {
totals.set(expense.category, (totals.get(expense.category) ?? 0) + expense.amountPaise);
return totals;
}, new Map());
} The sample tests · totals.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { totalByCategory } from './totals.mjs';
const month = [
{ category: 'food', amountPaise: 45000 },
{ category: 'travel', amountPaise: 120000 },
{ category: 'food', amountPaise: 30000 },
{ category: 'stay', amountPaise: 250000 },
];
test('totals per category, in the order the categories first appear', () => {
const totals = totalByCategory(month);
assert.deepEqual(totals, new Map([['food', 75000], ['travel', 120000], ['stay', 250000]]));
assert.deepEqual([...totals.keys()], ['food', 'travel', 'stay']);
});
test('names such as constructor and toString are ordinary categories', () => {
const odd = [{ category: 'constructor', amountPaise: 500 }, { category: 'toString', amountPaise: 700 }, { category: 'constructor', amountPaise: 100 }];
assert.deepEqual(totalByCategory(odd), new Map([['constructor', 600], ['toString', 700]]));
});
test('a missing or empty category counts as other', () => {
const loose = [{ amountPaise: 1000 }, { category: '', amountPaise: 250 }, { category: 'food', amountPaise: 400 }];
assert.deepEqual(totalByCategory(loose), new Map([['other', 1250], ['food', 400]]));
});
test('an amount that is not a whole number of paise is a TypeError', () => {
assert.throws(() => totalByCategory([{ category: 'food', amountPaise: 99.5 }]), TypeError);
assert.throws(() => totalByCategory([{ category: 'food', amountPaise: '500' }]), TypeError);
});
test('no expenses give an empty Map', () => {
assert.deepEqual(totalByCategory([]), new Map());
});
test('the expenses passed in do not change', () => {
const copy = month.map((e) => ({ ...e }));
totalByCategory(month);
assert.deepEqual(month, copy);
}); A hint
Start reduce() with new Map() as its initial value. In the function, work out the key first (the category, or 'other'), check the amount with Number.isInteger(), and finish with return totals.set(key, …): set() returns the Map itself, so that line both updates the totals and hands them to the next call.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What does reduce() do in JavaScript, and why should you pass an initial value?
reduce(fn, initial) turns an array into one value. It calls fn once for each item with an accumulator and the
item, and whatever fn returns becomes the accumulator for the next call; the last return value is the result. With
initial given, the first call gets it as the accumulator. Without it, the first item is the starting accumulator,
which goes wrong three ways: on an array of objects the first “sum” is an object plus a number, which gives a string
such as "[object Object]7"; an array with one item returns that item without calling fn; and an empty array throws
a TypeError. So pass an initial value of the result’s type every time: 0 for sums, new Map() or {} for totals,
[] for lists, and return the accumulator from every call.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A helper builds a lookup with rows.reduce((acc, r) => ({ ...acc, [r.id]: r }), {}). It takes about a tenth of a second for 1,000 rows, but a customer with 20,000 rows reports the page freezing for minutes. Explain the growth with numbers, fix it, and say when the spread version is acceptable.
-
Fresher to mid
const total = expenses.reduce((sum, e) => sum + e.amountPaise); shows "[object Object]120000" on the monthly report, and the same line crashes with a TypeError for a month with no expenses. Explain both failures, the fix, and one more case in which the missing initial value hides a bug.
Key takeaways
reduce(fn, initial)callsfn(accumulator, item, index, array)for each item; whatfnreturns is the next accumulator, and the last one is the result.- Pass an initial value every time. Without one, objects turn into
[object Object], a single item is returned as it is, and an empty array throws aTypeError. - Return the accumulator from every call: a block body without
returngives the next callundefined. - Change the accumulator you created and return it. Copying it with spread on every call made 2,000 rows take over a second in the run above; one object took under a millisecond.
- Use a Map,
Object.create(null)orObject.groupBy()when keys come from data: a{}accumulator inherits keys such astoString. - Prefer the method that names the job:
Object.groupBy()to group,new Map(list.map(…))to index,flat()to flatten,some()andevery()for yes-or-no; switch to a loop when the accumulator gets complicated.
References
- ECMAScript 2026 Language Specification: Array.prototype.reduce (Ecma International)
- ECMAScript 2026 Language Specification: Array.prototype.reduceRight (Ecma International)
- ECMAScript 2026 Language Specification: Object.groupBy (Ecma International)
- ECMAScript 2026 Language Specification: Map.prototype.set (Ecma International)
- ECMAScript 2026 Language Specification: Object.fromEntries (Ecma International)
- Array.prototype.reduce() (MDN Web Docs)
- Object.groupBy() (MDN Web Docs)
- Map (MDN Web Docs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress