JavaScript’s array API grew over twenty-five years without a naming rule for “this changes the array” versus “this gives you a new one”. map returns a new array. sort changes the array you called it on and then hands that same array back, so the return value looks like a fresh result. splice and slice differ by one letter and behave in opposite ways. If you’ve ever had a list in a React component reorder itself because a child sorted its props, you’ve met the problem.
This guide gives you a short list to memorise, explains the two methods that trip people most, and shows how the ES2023 copying methods (toSorted, toReversed, toSpliced, with) remove most of the risk.
The nine methods that change the array
Here’s the list. Every other built-in array method leaves the array alone.
| Mutates in place | Returns |
|---|---|
push(...items) |
the new length |
pop() |
the removed last element |
shift() |
the removed first element |
unshift(...items) |
the new length |
splice(start, count, ...items) |
an array of the removed elements |
sort(compare?) |
the same array, now sorted |
reverse() |
the same array, now reversed |
fill(value, start?, end?) |
the same array |
copyWithin(target, start, end?) |
the same array |
The non-mutating side is larger: map, filter, reduce, slice, concat, flat, flatMap, find, findIndex, includes, indexOf, join, some, every, at, and the iterators keys, values and entries.
A useful way to remember the split: the first four are stack and queue operations, and you’d expect a stack push to change the stack. The other five all rearrange or overwrite positions. If a method’s job is to compute something from the array, it doesn’t mutate. If its job is to reorganise the array, the older ones do.
Return values that hide the mutation
Look at what the mutators return, because that’s where the confusion starts. push and unshift return a number, which surprises people who write const next = list.push(x) and then try to render next. Running [1, 2, 3].push(4) gives 4, the length, not the array.
sort, reverse, fill and copyWithin return the array itself. That makes chaining work, but it also makes this line misleading:
const scores = [40, 100, 9];
const sorted = scores.sort();
console.log(sorted, scores, sorted === scores);
// [ 100, 40, 9 ] [ 100, 40, 9 ] trueTwo separate surprises sit in that output. First, sorted isn’t a new array: it’s scores, and scores has been reordered. Second, the order is wrong for numbers. Without a comparator, sort converts elements to strings and compares UTF-16 code units, so "100" comes before "40" because "1" is less than "4". Pass (a, b) => a - b for ascending numbers.
splice versus slice
These two cause more mix-ups than any other pair, so it’s worth slowing down.
slice(start, end) reads a section and returns it as a new array. The original is untouched. end is exclusive, and negative indexes count from the end, so list.slice(-2) is the last two items.
splice(start, deleteCount, ...items) edits the array: it removes deleteCount elements starting at start, inserts any items in their place, and returns the removed elements.
const sp = ['a', 'b', 'c', 'd'];
const removed = sp.splice(1, 2);
console.log(removed, sp); // [ 'b', 'c' ] [ 'a', 'd' ]The second argument is a count, not an end index, which is the other half of the confusion. slice(1, 2) gives one element; splice(1, 2) removes two.
If you can’t remember which is which, think of splicing film or rope: you cut something out of the original and join the ends. Slicing bread leaves you holding a slice and the loaf stays where it was. It’s a silly mnemonic but it holds up under interview pressure.
A sort that reorders someone else’s data
Here’s the bug in a form you’ll see in real code. A helper picks the top three players for a leaderboard widget:
function topThree(list) {
return list.sort((a, b) => b.points - a.points).slice(0, 3);
}
const players = [
{ n: 'Ana', points: 12 }, { n: 'Bo', points: 30 },
{ n: 'Cy', points: 21 }, { n: 'Di', points: 5 },
];
console.log(topThree(players).map(p => p.n)); // [ 'Bo', 'Cy', 'Ana' ]
console.log(players.map(p => p.n)); // [ 'Bo', 'Cy', 'Ana', 'Di' ]The function’s result is right. The side effect is that players has been resorted by points, so a different part of the page that displays players in signup order now shows them by score. In React, if players came from props or state, you’ve mutated it behind React’s back; the component that owns it won’t re-render, so the UI and the data disagree until something else triggers a render.
The .slice(0, 3) at the end is a red herring here. It creates a new array, but only after sort has already rearranged the input. The classic fix moves the copy before the sort: [...list].sort(...) or list.slice().sort(...). Both copy first, then sort the copy.
The ES2023 copying methods
ES2023 added a non-mutating twin for each of the three most dangerous mutators, plus a safe way to replace one index:
| Mutating | Copying twin |
|---|---|
sort(compare) |
toSorted(compare) |
reverse() |
toReversed() |
splice(start, count, ...items) |
toSpliced(start, count, ...items) |
arr[i] = value |
with(i, value) |
const s2 = [40, 100, 9];
console.log(s2.toSorted((a, b) => a - b), s2); // [ 9, 40, 100 ] [ 40, 100, 9 ]
const sp2 = ['a', 'b', 'c', 'd'];
console.log(sp2.toSpliced(1, 2), sp2); // [ 'a', 'd' ] [ 'a', 'b', 'c', 'd' ]
const w = ['a', 'b', 'c'];
console.log(w.with(1, 'B'), w); // [ 'a', 'B', 'c' ] [ 'a', 'b', 'c' ]Note that toSpliced returns the new array, not the removed elements. That’s the one behavioural difference from splice beyond mutation, and it makes sense: the point is to get the edited version.
with accepts negative indexes, so [1, 2].with(-1, 9) gives [1, 9]. Unlike plain assignment it refuses out-of-range indexes: [1, 2].with(5, 0) throws RangeError: Invalid index : 5 in Node 22, where arr[5] = 0 would have quietly grown the array and left holes.
These methods are a good fit for frozen data. Object.freeze([3, 1, 2]).sort() throws TypeError: Cannot assign to read only property '0' of object '[object Array]', while toSorted() on the same frozen array returns [1, 2, 3] without complaint.
Support is broad: Chrome and Edge 110, Safari 16, Firefox 115 and Node 20 all have them. If you support older browsers, a polyfill such as core-js covers them, or you can stay with copy-then-sort.
What the copies don’t protect
All of these return shallow copies. toSorted gives you a new array whose elements are the same objects:
const ps = [{ n: 'Ana', points: 12 }];
const t = ps.toSorted();
t[0].points = 99;
console.log(ps[0].points); // 99Reordering a copy is safe. Editing an object you reached through the copy edits the shared object. In interviews, saying “toSorted is non-mutating” is correct; adding “but the copy is shallow, so the objects are still shared” is what shows you’ve thought it through.
One small detail sometimes comes up: sparse arrays. The copying methods treat holes as undefined, while the old methods preserve them. [1, , 3].toSorted() gives [1, 3, undefined], whereas [1, , 3].sort() gives [1, 3, <1 empty item>]. You’ll rarely care, but it’s a nice detail if an interviewer probes.
Practice question: spot the mutation
Question. A teammate writes this in a component that receives items from its parent. What’s wrong, and how would you fix it with and without ES2023 methods?
function Latest({ items }) {
const recent = items.reverse().slice(0, 5);
return recent.map(i => i.title).join(', ');
}Answer. reverse() reverses items in place, and items belongs to the parent. Every render reverses it again, so the order flips back and forth between renders, and any sibling reading the same array sees it change underneath. The slice(0, 5) doesn’t help because it runs after the damage. With ES2023 you’d write items.toReversed().slice(0, 5). Without it, copy first: [...items].reverse().slice(0, 5). A cheaper option avoids reversing the whole list at all: items.slice(-5).reverse(), which copies only the last five and then reverses that small copy. It’s worth mentioning because it shows you’re thinking about which array each method is acting on, which is exactly what the bug got wrong.