Ch. 1 · JavaScript

JavaScript reduce(): Initial Value, Accumulator and Mutation

How Array.prototype.reduce works, why the initial value matters for empty arrays, and when mutating the accumulator is safe or quadratic.

~8 min readintermediateupdated Oct 6, 2026

“Implement a function that groups these orders by status using reduce,” or “What happens when you call reduce on an empty array?” Both questions show up constantly in JavaScript interviews. They look like syntax checks, but the interviewer is really listening for three things: whether you understand what the accumulator represents, whether you know why the initial value exists, and whether you can spot the hidden cost of the popular “spread the accumulator” style that turns a linear loop into a quadratic one.

A candidate who can explain those three points sounds like someone who has debugged real data pipelines rather than someone who memorised a one-liner.

Before you start

You should be comfortable with arrow functions, array methods such as map and filter, object and array spread syntax, and the difference between mutating an object and creating a new one. Knowing what Map is helps for the grouping examples. Big-O notation at the level of “linear versus quadratic” is enough for the performance discussion.

The short answer

reduce walks an array from left to right and threads a single value, the accumulator, through every call of your callback; whatever the callback returns becomes the accumulator for the next element. The second argument is the initial accumulator, and it should describe the result for zero elements: 0 for a sum, an empty Map for a grouping. Without it, reduce uses the first element as the start and throws a TypeError on an empty array. Mutating an accumulator you created inside the call is fine; copying it with spread on every step costs O(n²).

How it works

The callback receives four arguments: the accumulator, the current element, its index and the original array. The return value replaces the accumulator. When you pass an initial value, the callback runs once per element starting at index 0. When you do not, the first element becomes the accumulator and the callback starts at index 1.

const seen = [];
const result = [10, 20, 30].reduce((acc, value, index) => {
  seen.push(`acc=${acc} value=${value} index=${index}`);
  return acc + value;
});
console.log(seen);
// [ 'acc=10 value=20 index=1', 'acc=30 value=30 index=2' ]
console.log(result); // 60

try {
  [].reduce((a, b) => a + b);
} catch (error) {
  console.log(error.constructor.name, error.message);
  // TypeError Reduce of empty array with no initial value
}
console.log([].reduce((a, b) => a + b, 0)); // 0
JavaScript

There is a quieter trap too. With exactly one element and no initial value, the callback never runs at all; reduce just returns that element. If your callback was supposed to transform each item (for example extracting order.total), a single-element array returns the whole object instead of a number.

const orders = [{ id: 1, total: 25 }];
const wrong = orders.reduce((sum, order) => sum + order.total);
console.log(wrong); // { id: 1, total: 25 }
const right = orders.reduce((sum, order) => sum + order.total, 0);
console.log(right); // 25
JavaScript

The useful mental model is an invariant: after processing the first i elements, the accumulator holds the answer for those i elements. The initial value is then forced on you by the problem: it is the answer for zero elements.

Step-by-step walkthrough

Step 1: State the accumulator invariant

Before you write any code, say in one sentence what the accumulator means. For a total, “the sum of totals seen so far.” For grouping, “a map from status to the orders seen so far with that status.” For a maximum, “the largest order seen so far, or null if none.” Saying this out loud in an interview is worth more than writing clever code, because the next two steps follow from it.

const orders = [
  { id: 1, status: 'paid', total: 40 },
  { id: 2, status: 'open', total: 15 },
  { id: 3, status: 'paid', total: 5 },
];
// Invariant: largest order processed so far, or null for none.
const largest = orders.reduce(
  (best, order) => (best === null || order.total > best.total ? order : best),
  null,
);
console.log(largest.id); // 1
JavaScript

Step 2: Choose the identity value

The initial value must make the invariant true before any element is processed. For addition that is 0, for multiplication 1, for string concatenation '', for “largest so far” a sentinel such as null, and for grouping a fresh empty collection. Choosing it deliberately is what makes empty input behave predictably instead of throwing in production when a filter happens to return nothing.

const product = [2, 3, 4].reduce((acc, n) => acc * n, 1);
console.log(product); // 24
const none = [].reduce((acc, n) => acc * n, 1);
console.log(none); // 1
JavaScript

Step 3: Decide who owns the accumulator

This is the step most candidates skip. If the accumulator is a fresh object created by your initial value, nobody else holds a reference to it, so mutating it is not a side effect anyone can observe. Mutating it and returning it is linear. The fashionable alternative, { ...acc, [key]: value }, allocates a new object and copies every existing key on each iteration, so total work grows with 1 + 2 + … + n.

const statuses = Array.from({ length: 5000 }, (_, i) => `s${i}`);

let start = performance.now();
statuses.reduce((acc, s) => ({ ...acc, [s]: true }), {});
const spreadMs = performance.now() - start;

start = performance.now();
statuses.reduce((acc, s) => { acc[s] = true; return acc; }, {});
const mutateMs = performance.now() - start;

console.log(spreadMs > mutateMs * 10); // true
JavaScript

The exact timings vary by machine, but on 5,000 keys the spread version is typically well over ten times slower, and the gap widens as n grows.

Step 4: Keep caller data out of the blast radius

The rule is not “never mutate”; it is “never mutate what you did not create.” Passing a caller-owned object as the initial value, or editing the elements themselves, leaks changes outside the function.

const defaults = { paid: 0, open: 0 };
function countBad(list) {
  return list.reduce((acc, o) => { acc[o.status] += 1; return acc; }, defaults);
}
countBad([{ status: 'paid' }]);
countBad([{ status: 'paid' }]);
console.log(defaults); // { paid: 2, open: 0 }

function countGood(list) {
  return list.reduce((acc, o) => { acc[o.status] += 1; return acc; }, { paid: 0, open: 0 });
}
console.log(countGood([{ status: 'paid' }])); // { paid: 1, open: 0 }
JavaScript

Worked scenario

A dashboard groups orders by status. The original code used spread inside reduce and was fine with test data of 50 orders. In production, an account with 30,000 orders froze the tab for several seconds, and a second bug appeared: when an account had no orders, a related “total revenue” reducer without an initial value crashed the page.

function groupSlow(orders) {
  return orders.reduce(
    (acc, o) => ({ ...acc, [o.status]: [...(acc[o.status] ?? []), o] }),
    {},
  );
}
function revenueBroken(orders) {
  return orders.map((o) => o.total).reduce((a, b) => a + b);
}
try {
  revenueBroken([]);
} catch (error) {
  console.log(error.message); // Reduce of empty array with no initial value
}
JavaScript

The fix keeps the same output shape but makes ownership explicit. A Map created inside the call is mutated in place, each group array is pushed to, and the revenue reducer gets 0 as its identity.

function groupFast(orders) {
  return orders.reduce((groups, o) => {
    const list = groups.get(o.status);
    if (list) list.push(o);
    else groups.set(o.status, [o]);
    return groups;
  }, new Map());
}
function revenue(orders) {
  return orders.reduce((sum, o) => sum + o.total, 0);
}
const data = [
  { status: 'paid', total: 40 },
  { status: 'open', total: 15 },
  { status: 'paid', total: 5 },
];
const groups = groupFast(data);
console.log([...groups.keys()]); // [ 'paid', 'open' ]
console.log(groups.get('paid').length); // 2
console.log(revenue(data), revenue([])); // 60 0
JavaScript

Notice the group arrays still reference the original order objects. That is usually what you want, but say it out loud: if a consumer edits a grouped order, the original list sees the change. If that matters, copy the records explicitly. For modern runtimes, Map.groupBy(data, (o) => o.status) does the same grouping in one call and is worth mentioning.

Common mistake

  • “reduce always needs to be pure, so mutation is wrong.” Purity is about observable effects. Mutating a fresh accumulator is unobservable; mutating input or a shared default is not.
  • Omitting the initial value “because the array always has items.” Filters, API pages and search results return empty arrays sooner or later. The one-element case also silently skips your callback.
  • Spreading the accumulator in a loop. It reads nicely and is quadratic. Interviewers often ask for the complexity of exactly this pattern.
  • Using reduce for everything. If you need early exit (finding the first match), find or a for...of loop with break is clearer. reduce always visits every element.
  • Forgetting to return the accumulator. A block-bodied arrow function without return hands undefined to the next iteration.

Verify the behavior

Run a quick check with Node’s built-in test runner, covering empty input, a single element and multiple groups, and confirming the input array is untouched.

import test from 'node:test';
import assert from 'node:assert/strict';

const revenue = (orders) => orders.reduce((sum, o) => sum + o.total, 0);
const group = (orders) =>
  orders.reduce((m, o) => m.set(o.status, [...(m.get(o.status) ?? []), o]), new Map());

test('revenue handles empty and single inputs', () => {
  assert.equal(revenue([]), 0);
  assert.equal(revenue([{ total: 7 }]), 7);
});

test('grouping covers every order once and leaves input alone', () => {
  const input = [{ status: 'a', total: 1 }, { status: 'b', total: 2 }, { status: 'a', total: 3 }];
  const snapshot = structuredClone(input);
  const groups = group(input);
  const count = [...groups.values()].reduce((n, list) => n + list.length, 0);
  assert.equal(count, input.length);
  assert.deepEqual(input, snapshot);
});
JavaScript

Save it as reduce.test.mjs and run node --test reduce.test.mjs; both tests pass. The small spread inside group copies only one group’s array, not the whole accumulator, so it stays close to linear for a handful of groups.

Follow-up questions

What is the difference between reduce and reduceRight? reduceRight walks from the last index to the first. It matters for non-commutative operations such as function composition or string building.

Does reduce visit holes in sparse arrays? No. Like forEach, it skips missing indices, so [1, , 3].reduce((a, b) => a + b, 0) is 4 and the callback runs twice.

Can you implement map with reduce? Yes: arr.reduce((out, x, i) => { out.push(fn(x, i)); return out; }, []). Pointing out that you push instead of spreading shows you understand Step 3.

When would you not use reduce? When a plain loop is clearer, when you need early termination, or when a dedicated method exists (some, every, find, Map.groupBy, Object.fromEntries).

Interview exercise

Write indexById(users) that returns an object mapping each user’s id to the user. It must handle an empty array, must not modify the input, and must run in linear time. Then explain what your function returns when two users share an id.

Answer and reasoning

function indexById(users) {
  return users.reduce((index, user) => {
    index[user.id] = user;
    return index;
  }, {});
}
const users = [{ id: 'a', name: 'Ada' }, { id: 'b', name: 'Bo' }, { id: 'a', name: 'Al' }];
console.log(indexById(users));
// { a: { id: 'a', name: 'Al' }, b: { id: 'b', name: 'Bo' } }
console.log(indexById([])); // {}
JavaScript

The invariant is “an object mapping every id processed so far to its latest user,” so the identity value is {}, which also covers empty input. The accumulator is created inside the call, so mutating it is safe and keeps the work linear; the input array and the user objects are not changed. With duplicate ids, the last user wins because later assignments overwrite earlier ones. A strong answer mentions that you might prefer to throw on duplicates, and that Object.fromEntries(users.map((u) => [u.id, u])) or a Map are equally valid alternatives (a Map also avoids surprises with ids such as __proto__).

Continue learning

Practise more with the JavaScript interview questions and check yourself with the JavaScript MCQs. Related notes: which array methods mutate and shallow versus deep array copies. The official reference is Array.prototype.reduce on MDN.

More in JavaScript

read ✓JavaScript · easy

JavaScript Array Copies and Shared Objects

Why [...arr] and slice() copy the array but not the objects inside it, and how to choose a targeted update, structuredClone or JSON safely.

~7 min readread →
esc