JavaScript Array Methods: map, filter, reduce, and the Mutation Traps
Modern JavaScript style is built on array methods. Where older code looped with counters and accumulated into temp variables, todayโs code declares intent: transform this list, keep these items, boil this down to one value. Reading and writing this style fluently is non-negotiable โ itโs the dominant idiom in every codebase, framework, and code review youโll touch.
This guide covers the core methods with judgment (including when a plain loop is still right), reduce without the mystique, the sort traps that bite everyone, and the mutating-vs-non-mutating distinction that quietly causes UI bugs.
The Big Three
map โ transform every element, same length, new array:
const orders = [ { id: 1, paise: 49900, status: "paid" }, { id: 2, paise: 129900, status: "pending" }, { id: 3, paise: 9900, status: "paid" },];
const rupees = orders.map((o) => o.paise / 100); // [499, 1299, 99]const labels = orders.map((o) => `#${o.id}: โน${o.paise / 100}`);The contract matters as much as the mechanics: map is one-in-one-out transformation. Using it for side effects while ignoring the result (orders.map(o => save(o)) with no use of the returned array) is a misuse the whole ecosystem frowns on โ thatโs forEach or a loopโs job. If you return nothing from the callback, youโve built an array of undefined.
filter โ keep matching elements, shorter-or-equal, new array:
const paid = orders.filter((o) => o.status === "paid");const big = orders.filter((o) => o.paise > 20_000);The predicate returns truthy/falsy โ which enables terse idioms like values.filter(Boolean) to strip null/undefined/empty (and, caution, also strips legitimate 0).
reduce โ boil the array down to one value:
const totalPaise = orders.reduce((sum, o) => sum + o.paise, 0);
// grouping โ the other canonical reduceconst byStatus = orders.reduce((acc, o) => { (acc[o.status] ??= []).push(o); return acc;}, {});// { paid: [...], pending: [...] }Reduceโs reputation for inscrutability is earned only when misused. The model: an accumulator starts at the seed (0, {}), the callback folds each element in, the final accumulator is the answer. Two disciplines keep it readable: always pass the seed (seedless reduce uses element one as the accumulator โ a type-mismatch trap and an exception on empty arrays), and if the callback exceeds ~3 lines, use a loop instead โ a for..of building the same accumulator is identical logic with better debuggability. Reduce is for when the fold is simple; itโs not a badge of sophistication. (For grouping specifically, the new Object.groupBy(orders, o => o.status) replaces the reduce idiom where available.)
Chaining composes the three into the standard pipeline shape โ read it aloud like streams in Java:
const refundRupees = orders .filter((o) => o.status === "paid") .map((o) => o.paise / 100) .reduce((a, b) => a + b, 0);The Supporting Cast Youโll Use Weekly
// find / findIndex โ first match (or undefined / -1)const order = orders.find((o) => o.id === 2);const idx = orders.findIndex((o) => o.status === "pending");
// some / every โ boolean questions, short-circuitingorders.some((o) => o.paise > 100_000); // "any big order?"orders.every((o) => o.status === "paid"); // "all settled?"
// includes / indexOf โ for primitives (=== semantics; includes handles NaN)["a", "b"].includes("b"); // true
// slice โ non-mutating subrange (half-open, like substring)const firstTwo = orders.slice(0, 2);const lastOne = orders.slice(-1);
// flat / flatMap โ nesting control[[1, 2], [3]].flat(); // [1, 2, 3]orders.flatMap((o) => o.items); // map + flatten one level: all items across orders
// at โ negative indexing, finallyorders.at(-1); // last element, no length arithmetic
// Array.from โ materialize iterables & generate sequencesArray.from({ length: 5 }, (_, i) => i * 10); // [0, 10, 20, 30, 40]Small judgment notes: find returning undefined composes with optional chaining (orders.find(...)?.paise ?? 0); some/every beat filter(...).length > 0 (they stop early); and โdoes this list of objects contain Xโ needs some with a predicate โ includes compares references, so structurally-equal objects wonโt match.
Sort: Two Traps in One Method
// Trap 1: default sort is STRINGWISE โ even for numbers[10, 9, 100].sort(); // [10, 100, 9] โ lexicographic![10, 9, 100].sort((a, b) => a - b); // [9, 10, 100] โ always pass a comparator
// objects: comparator by key, chained tiebreakersorders.sort((a, b) => b.paise - a.paise); // descending by amountusers.sort((a, b) => a.city.localeCompare(b.city) || a.age - b.age);
// Trap 2: sort MUTATES the array it's called on โ and returns itconst sorted = orders.sort(...); // 'orders' itself is now reordered too!The comparator contract: negative โ a first, positive โ b first, zero โ tie. a - b ascending, b - a descending, localeCompare for human-correct strings. Get the contract wrong (returning booleans is the classic โ (a, b) => a > b returns true/false, i.e. 1/0, never negative) and you get subtly wrong orders that pass casual testing.
Trap 2 is the bridge to this guideโs most important section.
Mutating vs. Non-Mutating: The Distinction That Causes UI Bugs
Array methods split into two families, and knowing which is which is load-bearing knowledge:
| Non-mutating (returns new) | Mutating (changes in place) |
|---|---|
map, filter, slice, concat, flat, toSorted, toReversed, with | push, pop, shift, unshift, splice, sort, reverse, fill |
Why it matters beyond hygiene: modern UI frameworks detect changes by reference comparison โ โis this the same array object?โ Mutating an array keeps the same reference, so React/signal-based frameworks may conclude nothing changed and skip re-rendering. The bug presents as โmy list updates in the data but not on screen,โ and its cause is a push or sort where a new array was needed.
ES2023 finally shipped non-mutating twins โ prefer them when you have them:
const sorted = items.toSorted((a, b) => a.paise - b.paise); // items untouchedconst flipped = items.toReversed();const patched = items.with(2, newItem); // copy with index 2 replacedAnd the spread-based idioms that predate them remain everywhere:
const appended = [...items, newItem]; // non-mutating pushconst removed = items.filter((x) => x.id !== id); // non-mutating deleteconst updated = items.map((x) => x.id === id ? { ...x, qty } : x); // non-mutating updateconst sortedOld = [...items].sort(cmp); // copy-then-sort, pre-toSortedThat update idiom โ map + conditional spread โ is the single most-typed pattern in React state code; the objects guide unpacks the { ...x, qty } half. The underlying discipline is one sentence: treat arrays you didnโt just create as read-only; produce new ones. (Also remember const doesnโt help here โ it freezes the binding, not the array.)
When a Plain Loop Is Still Right
The judgment section, because method-maximalism is its own disease:
- Early exit on complex conditions โ
for..ofwithbreakbeats contortedsometricks. - Multi-step accumulation โ building two or three results in one pass reads better as one loop than as three chained passes or one gnarly reduce.
- Async per element โ
for..ofwithawaitfor sequential work;map+Promise.allfor parallel; neverforEach(the trap). - Hot paths with huge arrays โ each chained method is a full pass allocating an intermediate array; a fused loop does one pass, zero allocations. Irrelevant at 100 elements, real at 10 million.
The read-aloud test from the streams guide transfers verbatim: if the chain narrates cleanly (โpaid orders, amounts, summedโ), itโs good; if narration needs breath marks, extract helpers or loop.
Three Real Tasks, Composed
Method fluency is combinatorial โ the payoff is composing them under realistic requirements. Work each before reading the solution.
Task 1: Top 3 customers by total spend (from a flat orders array with customer and paise):
const top3 = Object.entries( orders.reduce((acc, o) => { acc[o.customer] = (acc[o.customer] ?? 0) + o.paise; return acc; }, {}) ) .toSorted(([, a], [, b]) => b - a) .slice(0, 3) .map(([customer, paise]) => `${customer}: โน${paise / 100}`);The shape to recognize: aggregate into an object, then Object.entries back into array-land for sorting and slicing โ the same two-stage pipeline as Java streamsโ โtop Nโ, with destructuring doing the pair-handling.
Task 2: Are two arrays equal by contents? No built-in โ and now you can write the idiom: a.length === b.length && a.every((x, i) => x === b[i]). Note everyโs rarely-used second parameter (the index) doing the pairwise walk.
Task 3: Paginate. items.slice((page - 1) * size, page * size) โ half-open ranges making the arithmetic clean, and a reminder that not everything needs a callback method; slice alone is the whole feature.
Frequently Asked Questions
forEach vs for..of? for..of is the more capable default โ supports break/continue/await and reads as what it is. forEach is fine for genuinely simple per-element side effects at a chainโs end. Never for async.
How do I get unique values? Primitives: [...new Set(values)] โ idiomatic and fast. Objects by key: a Map keyed on the identity field โ [...new Map(items.map(i => [i.id, i])).values()] (keeps the last occurrence per id; a seen-Set + filter keeps the first).
Why did map(parseInt) mangle my numbers? ["10","10","10"].map(parseInt) โ [10, NaN, 2] โ map passes (element, index, array), and parseInt interprets the index as a radix. The fix: map(Number) or map(s => parseInt(s, 10)). General lesson: be careful passing multi-parameter functions directly as callbacks.
Are these methods slower than loops? Marginally, per-call โ and irrelevantly for typical sizes; engines optimize them hard. Choose by clarity; optimize the rare proven hot spot by fusing into a loop.
Empty-array edge cases? map/filter โ []; reduce with seed โ the seed; some โ false; every โ true (vacuous truth โ surprising but logical, and occasionally a bug when โeveryโ of nothing shouldnโt count as success).
Why does my array have holes, and do methods skip them? Sparse arrays (new Array(3), deleted indexes, trailing commas gone wrong) have missing slots, and classic methods (map, forEach) skip holes while newer ones (fill, spread, Array.from) treat them as undefined โ a consistency mess best avoided by never creating sparse arrays. Array.from({ length: n }, fn) is the hole-free way to generate.
Iterator Helpers: The Next Chapter Arriving Now
A recent addition worth knowing as it lands everywhere: iterator helpers put map/filter/take directly on iterators, evaluated lazily โ no intermediate arrays:
const firstThreeBig = orders.values() .filter((o) => o.paise > 50_000) .map((o) => o.id) .take(3) .toArray();Unlike array-method chains (each step materializes a full array), iterator chains pull one element through the whole pipeline at a time and stop when take(3) is satisfied โ the same lazy, short-circuiting model as Java streams, arriving in JS twenty years of ecosystem later. For typical small arrays the difference is academic; for large data, generated sequences, and early-exit searches, itโs the tool the language was missing. The mental model transfers wholesale from this guide โ same verbs, lazy engine.
Self-Check
const xs = [3, 1, 2];
// 1const ys = xs.sort();console.log(xs, ys === xs);
// 2console.log([1, 2, 3].map((n) => { n * 2 }));
// 3console.log([[1], [2, 3]].flatMap((a) => a.map((n) => n * 10)));
// 4 โ what's the bug pattern?const total = items.reduce((sum, i) => sum + i.paise);
// 5console.log([5, 25, 125].filter(Boolean).every((n) => n % 5 === 0));Answers: (1) [1,2,3], true โ sort mutated xs and returned the same reference (and got lucky: single digits sort โcorrectlyโ stringwise โ a trap that hides until 10 shows up). (2) [undefined, undefined, undefined] โ braces without return, the arrow-body trap. (3) [10, 20, 30]. (4) missing seed โ on empty items it throws; with items, the first object becomes the accumulator and sum + i.paise becomes string/NaN nonsense. Always seed. (5) true.
Next: the other half of daily data wrangling โ Objects, Destructuring, and Spread: shorthand, computed keys, immutable updates, deep-vs-shallow copies, and Map/Setโs proper place.