Immutable Array Methods

Here’s a bug that has cost more engineer-hours than it has any right to:

const scores = [40, 100, 1, 5, 25, 10];
const top = scores.sort((a, b) => b - a);

// You wanted a sorted *copy*. You also mutated the original.
console.log(scores); // [100, 40, 25, 10, 5, 1]  ← changed!

sort sorted scores in place and then handed you back the very same array. There is no separate “sorted copy” — top and scores are two names for one array. If some other part of your program still expected scores in its original order, it just silently broke.

The same trap hides in reverse (mutates, returns the same array) and splice (mutates, returns something else entirely — the removed elements, not the modified array). For years the workaround was to clone first: [...scores].sort(...). It works, but it’s a ritual you have to remember every single time, and forgetting it produces bugs that only show up two functions away from the mistake.

ES2023 added the fix directly to the language: four methods that do the same work but return a brand-new array and leave the original untouched — toSorted, toReversed, toSpliced, and with. The same release also shipped findLast and findLastIndex for searching from the end. All six are Baseline Widely available (in every major browser since mid-2023, and findLast since August 2022), so you can reach for them in production today without a polyfill.

Mutating vs. copying: the core distinction

Every array method falls into one of two camps. A mutating method reaches into the array you called it on and rewrites it. A copying method builds a fresh array and returns that, never touching the source.

arr.reverse() — mutatingbefore123after321same array, contents overwrittenarr.toReversed() — copyingsource123unchanged ✓123returns a new array321
A mutating method rewrites the boxes in place — same array, new contents. A copying method leaves the source alone and returns a separate array.

The four new methods pair up one-to-one with the old mutators. Read this table as “when you want the result as a copy, reach for the right column”:

Mutates in place Returns a copy What it does
sort(cmp) toSorted(cmp) orders elements
reverse() toReversed() flips order
splice(start, count, ...items) toSpliced(start, count, ...items) remove/insert at an index
arr[i] = v with(i, v) replace one element

toSorted — sorting without the side effect

toSorted takes the same optional compare function as sort and returns a new, sorted array. The source stays in its original order.

const scores = [40, 100, 1, 5, 25, 10];

const ranked = scores.toSorted((a, b) => b - a);

console.log(ranked); // [100, 40, 25, 10, 5, 1]
console.log(scores); // [40, 100, 1, 5, 25, 10]  ← untouched ✓

The compare function works exactly as before: return a negative number to put a first, positive to put b first, 0 to keep their relative order. And the classic gotcha of the default (string) sort still applies — with no compare function, elements are compared as strings, so [1, 5, 10].toSorted() gives [1, 10, 5]. Copying doesn’t change how it sorts, only what it does to the original.

let a = [3, 1, 2]; a.sort();123a itself is now sorted — original order gonelet b = [3, 1, 2]; b.toSorted();b (kept as-is)312new array returned123
sort() overwrites the same three boxes; toSorted() leaves them and emits a fresh, sorted row. Watch which array ends up changed.

Try it yourself. Edit the numbers, then sort a copy any way you like — the “original after” line proves the source list is never disturbed, no matter which sort you run.

interactivetoSorted leaves the source alone

toReversed — flip a copy

The simplest of the set. No arguments, returns a new array in reverse order:

const queue = ["a", "b", "c"];
const rev = queue.toReversed();

console.log(rev);   // ["c", "b", "a"]
console.log(queue); // ["a", "b", "c"]  ← untouched ✓

Compare that to the old approach, where the two-name trap bites again:

const queue = ["a", "b", "c"];
const rev = queue.reverse();

console.log(queue === rev); // true — they are the same array!
console.log(queue);         // ["c", "b", "a"] — reversed in place

toSpliced — the return value finally makes sense

splice is the array multitool: remove, insert, or replace at any index. Its signature is splice(start, deleteCount, ...items). But it has a genuinely confusing return value — it hands back the elements it removed, not the modified array. People trip over this constantly:

const nums = [1, 2, 3, 4, 5];
const result = nums.splice(1, 2); // remove 2 elements starting at index 1

console.log(result); // [2, 3]  ← the *removed* items, surprise
console.log(nums);   // [1, 4, 5]  ← the mutated array is over here

toSpliced takes the identical arguments but returns the resulting array and leaves the source alone. That is almost always what you actually wanted:

const nums = [1, 2, 3, 4, 5];
const without = nums.toSpliced(1, 2);

console.log(without); // [1, 4, 5]  ← the new array ✓
console.log(nums);    // [1, 2, 3, 4, 5]  ← untouched ✓

It handles insertion and replacement too. Pass a deleteCount of 0 to insert without removing, or a non-zero count plus items to replace:

const months = ["Jan", "Mar", "Apr"];

months.toSpliced(1, 0, "Feb");        // ["Jan", "Feb", "Mar", "Apr"]  (insert)
months.toSpliced(1, 1, "February");   // ["Jan", "February", "Apr"]    (replace one)
months.toSpliced(0, 2);               // ["Apr"]                       (delete two)
source = [“a”, “b”, “c”, “d”].toSpliced(1, 2, “x”)source“a”“b”“c”“d”keepremoved (2 from idx 1)keepresult (new array)“a”“x”“d”
toSpliced(1, 2, 'x') builds a new array from three slices: everything before start, then the inserted items, then everything from start+deleteCount onward.

with — replace one element, get a copy

Assigning by index, arr[i] = value, is the most basic mutation there is. with(index, value) is its copying twin: it returns a new array identical to the original except for one slot.

const row = [10, 20, 30, 40];

const updated = row.with(2, 99);

console.log(updated); // [10, 20, 99, 40]
console.log(row);     // [10, 20, 30, 40]  ← untouched ✓

Two details worth knowing. First, with accepts negative indices, counting from the end — row.with(-1, 99) replaces the last element. Second, unlike bracket assignment (which happily grows the array when you write past the end), with throws a RangeError if the index is out of bounds. That’s a feature: it catches off-by-one mistakes instead of silently creating holes.

const row = [10, 20, 30];

row.with(-1, 99); // [10, 20, 99]  — last element
row.with(5, 99);  // RangeError: Invalid index : 5
original102030400123.with(2, 99)copy10209940
with(2, 99) copies every element across, swapping only the cell at index 2. The original array is never touched.

Here it is live. Click any cell to replace it with the value in the box — with builds the copy while the original row stays exactly as it started.

interactivewith(i, value) replaces one slot into a copy

Because with returns an array, you can chain it and keep going. This reads cleanly and never mutates anything along the way:

[1, 2, 3, 4, 5]
  .with(0, 10)   // [10, 2, 3, 4, 5]
  .with(-1, 50)  // [10, 2, 3, 4, 50]
  .map(n => n * 2); // [20, 4, 6, 8, 100]

Why this matters: predictable state

If you’ve written React, Vue, Solid, or anything with a reactive store, you already know the golden rule: never mutate state directly, produce a new value. Frameworks detect change by comparing references. Mutate the array in place and the reference is identical before and after — so the framework sees “nothing changed” and skips the re-render.

// ❌ Bug: same reference, React may not re-render
setItems(prev => {
  prev.sort((a, b) => a - b); // mutates the existing state array
  return prev;                 // same reference as before
});

// ✅ New reference every time — the update is detected
setItems(prev => prev.toSorted((a, b) => a - b));

This is where the copying methods shine. Before ES2023 you had to spread-then-mutate — [...prev].sort(...), [...prev].reverse(), [...prev.slice(0, i), value, ...prev.slice(i + 1)] for a single replacement. That last one is genuinely awful to read. Compare:

// Old: replace one item immutably
const next = [...prev.slice(0, i), value, ...prev.slice(i + 1)];

// New: says exactly what it means
const next = prev.with(i, value);
mutating call — shared array changes for bothstatedraft[3, 1, 2]draft.sort() rewrites this one box —state sees the change toocopying call — a separate array, no leakstatenext[3, 1, 2][1, 2, 3]next = state.toSorted()state stays [3, 1, 2] ✓
Two variables pointing at one array is the setup for a shared-mutation bug: a mutating call through one name silently changes what the other sees. A copying call breaks the link.

findLast and findLastIndex — search from the end

find and findIndex scan an array from the front and stop at the first match. Their ES2023 companions, findLast and findLastIndex, do the same job but scan from the back, returning the last match instead.

You could always reverse-and-find, but that’s wasteful (it builds a whole reversed array) and it scrambles the index math. Scanning from the end directly is cleaner and often faster, because it stops as soon as it finds a match near the tail.

const readings = [5, 12, 50, 130, 44];

readings.findLast(n => n > 45);      // 130  (value of last match)
readings.findLastIndex(n => n > 45); // 3    (its index)

readings.find(n => n > 45);          // 50   (first match, for contrast)
readings.findIndex(n => n > 45);     // 2

The callback receives the usual (element, index, array). When nothing matches, findLast returns undefined and findLastIndex returns -1 — same sentinels as their forward siblings.

[5, 12, 50, 130, 44].findLast(n => n > 45)scan512501304401234starts at index 4, moves left, stops at index 3 →returns 130
findLast walks the array right-to-left, testing each element, and returns the first one that passes. Here it stops at index 3 (value 130), never even looking at indices 0–2.

Change the threshold below and watch the two matches move independently: find locks onto the first cell that passes (highlighted from the left), findLast onto the last (from the right).

interactivefind vs findLast on one array

Where does searching from the end actually come up? More often than you’d think:

  • The most recent entry in an append-only log: events.findLast(e => e.type === "error").
  • The last item matching a filter in an ordered list, like the final completed step in a wizard.
  • Parsing, where you want the last delimiter or the last valid token before some boundary.

Stepping through the difference

To make the mutation-vs-copy split concrete, here’s the exact moment a shared reference bites, versus the copying method sidestepping it.

Same code shape, two very different outcomes
1/6
Variables
a="[3,1,2]"
a points at a fresh array [3, 1, 2].

Edge cases and gotchas worth remembering

A few behaviors that will save you a debugging session:

Sparse arrays become dense. All four copying methods read empty slots as undefined and write real undefined values into the result. A hole goes in, an undefined comes out.

console.log([1, , 3].toReversed()); // [3, undefined, 1]  — the hole is filled

with and toSpliced throw or clamp on bad indices differently. with throws a RangeError for any index outside -length … length-1. toSpliced, like splice, clamps — a start beyond the end just appends, a negative start counts from the end, and one too negative is treated as 0. Don’t assume they validate the same way.

They’re generic. Like most array methods, these work on array-like objects via .call, e.g. Array.prototype.toSorted.call({ length: 2, 0: "b", 1: "a" }). Rarely needed, but it explains why they don’t strictly require a real array.

Summary

  • Old sort, reverse, and splice mutate in place. sort/reverse also return the same array (an aliasing trap), and splice returns the removed elements, not the modified array.
  • ES2023 added copying twins that return a fresh array and leave the source untouched: toSorted, toReversed, toSpliced, and with.
  • with(index, value) replaces one element by index, supports negative indices, and throws RangeError when out of bounds — cleaner than the old slice-spread-slice pattern.
  • findLast and findLastIndex search from the end, returning the last match (or undefined / -1). Prefer them over filter(...).at(-1) when you only need one element.
  • These methods make shallow copies — nested objects are still shared. Copy the level you mutate.
  • This is the backbone of predictable state in reactive frameworks: a new reference is what tells them something changed.
  • All six are Baseline Widely available (mid-2023 for the four copiers, 2022 for the finders) — safe to use directly in current browsers and Node 20+.