You need to group or count an array of objects by a key using reduce. How do you design the accumulator, and what is wrong with returning { ...acc, [key]: value } from the reducer?
answer
- the accumulator is private to the fold
- copying versus assigning per step
- cost grows with keys already seen
- always hand the accumulator back
- plain objects inherit keys
basics
~20 sSeed reduce with a fresh empty object or Map, then mutate that accumulator in place and return it. Spreading the accumulator on every iteration copies every key accumulated so far, turning a linear fold into quadratic work and heavy garbage for no safety benefit.
solid answer
~50 sThe accumulator you create in the initial value is private to the fold — nothing outside the reduce can observe it — so mutating it in place is safe, idiomatic and linear: `items.reduce((acc, x) => { (acc[x.k] ||= []).push(x); return acc; }, {})`. Rewriting it as `({ ...acc, [x.k]: ... })` allocates a new object each step and copies every key already accumulated, so the total work grows with the square of the number of distinct keys and the allocation churn is real on large inputs. The two other things to get right are always returning the accumulator — forgetting the `return` makes the next call receive `undefined` and throw — and choosing the container: a plain `{}` inherits prototype keys and mishandles a `__proto__` key, so use `Object.create(null)` or a `Map` when keys come from user data. Since ES2024, `Object.groupBy` and `Map.groupBy` do the grouping for you.
code
javascript · 19 linesconst people = [
{ name: 'ada', dept: 'eng' },
{ name: 'grace', dept: 'eng' },
{ name: 'kay', dept: 'design' },
];
// linear: mutate the private accumulator
const byDept = people.reduce((acc, p) => {
(acc[p.dept] ||= []).push(p);
return acc;
}, Object.create(null));
console.log(byDept.eng.length, byDept.design.length); // 2 1
// counting with the same shape
const counts = ['a', 'b', 'a'].reduce((acc, w) => {
acc[w] = (acc[w] || 0) + 1;
return acc;
}, {});
console.log(counts); // { a: 2, b: 1 }go deeper
Be able to write the grouping fold correctly: seed reduce with an empty object, create the bucket array if it is missing, push, and return the accumulator every time.
Explain why the accumulator may be mutated — it is created by the initial value and unreachable elsewhere — and what the spread version costs: a fresh object plus a copy of every key seen so far, on every element.
Show that you would catch this in review of real code, argue the cost concretely on realistic input sizes, and pick a prototype-less object or a Map when the grouping keys come from user or network data.
Own the convention: when built-in grouping replaces hand-rolled folds, how the team treats untrusted keys as a class of hazard, and where readability of a plain loop beats a clever reducer in shared code.
## The shape of the fold Grouping and counting are the two most common non-arithmetic uses of `reduce`, and both hinge on the same idea: the initial value creates a container, and each step folds one element into it. ```js // group const byDept = people.reduce((acc, p) => { (acc[p.dept] ||= []).push(p); return acc; }, {}); // count const counts = words.reduce((acc, w) => { acc[w] = (acc[w] || 0) + 1; return acc; }, {}); ``` Both are single passes with constant work per element. ## Why mutating the accumulator here is correct Functional style discourages mutation because shared state is what makes code hard to reason about. That argument does not apply to the object created by `reduce`'s initial value: it was allocated by this call, no other code holds a reference to it until the fold finishes, and it is not the input. Mutating it is a *local* mutation inside an otherwise pure function — the fold as a whole still takes an array and returns a new value, leaving the source untouched. What would be wrong is mutating something you were handed: pushing into an array that lives in outer scope, or writing to a property of the elements. Keep the distinction sharp in an interview; candidates who say "never mutate in a reducer" usually cannot explain which object they mean. ## The spread anti-pattern ```js // looks purer, behaves badly const lookup = items.reduce((acc, x) => ({ ...acc, [x.id]: x }), {}); ``` Each iteration builds a **new** object and copies every key accumulated so far. Over `n` distinct keys that is roughly `n * n / 2` property copies plus `n` short-lived objects for the garbage collector, versus `n` assignments for the mutating version. On a few dozen items nobody notices; on a few tens of thousands it is the difference between milliseconds and seconds, and it shows up in a profile as time spent in object allocation. The usual defence — "it is immutable, so it is safer" — does not hold: the intermediate objects are unreachable the instant the next one is built, so the immutability buys nothing that mutating a private accumulator does not already give you. The same reasoning applies to `[...acc, x]` inside a reducer, which is the array-shaped version of the same mistake. ## Always return the accumulator ```js items.reduce((acc, x) => { acc[x.id] = x; }, {}); // TypeError on the second element ``` A braced arrow body with no `return` yields `undefined`, which becomes the accumulator for the next call — so `acc[x.id] = x` throws `TypeError: Cannot set properties of undefined`. With a one-element array it does not even throw; it silently returns `undefined`. Two habits avoid it: use the comma-operator form `(acc[x.id] = x, acc)` if you like terse reducers, or always write the explicit `return acc`. ## Choosing the container A plain object literal is not a clean dictionary. Its keys are coerced to strings, it inherits from `Object.prototype` so `acc['toString']` is truthy before you ever wrote to it, and assigning the key `'__proto__'` on a plain object goes through the inherited setter rather than creating an own property. When the keys come from user or network data, prefer: ```js const safe = items.reduce((acc, x) => { (acc[x.k] ||= []).push(x); return acc; }, Object.create(null)); const asMap = items.reduce((acc, x) => acc.set(x.k, [...(acc.get(x.k) || []), x]), new Map()); ``` `Object.create(null)` gives a prototype-less object; a `Map` additionally preserves non-string keys and insertion order and reports its size directly. ## Do you even need reduce? Since **ES2024** the language has the operation built in: ```js Object.groupBy(people, p => p.dept); // null-prototype object of arrays Map.groupBy(people, p => p.dept); // Map, keys may be objects ``` Where your runtime supports it, that is clearer than any hand-rolled reduce and gets the prototype question right by construction. And when the fold is doing nothing an ordinary loop would not do more legibly, a plain accumulation loop is a perfectly professional answer — `reduce` earns its place when the fold is the point, not when it is a disguise for a loop. ## What a strong answer sounds like Name the private-accumulator argument for mutating in place, quantify the spread version's cost, mention the missing-`return` trap, and note the prototype hazard with a plain object — then say you would reach for `Object.groupBy` where it is available.
- Is mutating the accumulator inside a reducer a violation of functional purity?No, as long as the accumulator is the object your own initial value created. Nothing outside the fold can observe it, and the reduce as a whole still returns a new value without touching its input, so the function is pure from the caller's view. What would break purity is pushing into an array from an enclosing scope, or writing to properties of the elements being folded.
- Why might a plain object literal be the wrong accumulator for grouping user-supplied keys?A `{}` inherits from `Object.prototype`, so lookups for keys like `toString` or `constructor` return inherited functions before you have written anything, and an assignment to the key `__proto__` goes through the inherited setter instead of creating an own property. `Object.create(null)` removes the prototype entirely; a `Map` additionally allows non-string keys and keeps insertion order.
- When would you skip reduce here entirely?When the runtime has `Object.groupBy` or `Map.groupBy` from ES2024, which express grouping directly and produce a null-prototype result. And when the fold is doing what a plain accumulation loop would say more clearly — a reducer whose only job is to push into an array is boilerplate around a loop, and reviewers read the loop faster.
- What breaks if the reducer body forgets to return the accumulator?The next invocation receives `undefined` as the accumulator, so the first property assignment throws `TypeError: Cannot set properties of undefined`. With a single-element array it fails more quietly: no callback error occurs and the reduce simply evaluates to `undefined`, which then surfaces far from the cause. Writing `return acc` explicitly is the cheap defence.
saying these in an interview costs you the question
- Insists spreading the accumulator is safer than mutating it
- Says all mutation inside a reducer is impure
- Forgets to return the accumulator from the callback
- Uses a plain object for untrusted keys without thought
- Claims reduce is always faster than a plain loop