Immutable Array Methods: with(), toSorted(), toReversed(), toSpliced()
AI generated
JS
() =>
JavaScript · Language Features · State Management
Changing arrays without mutating them:
with(), toSorted(), toReversed(), toSpliced()

Four new array methods return a changed copy instead of mutating the original. That finally makes sort(), reverse() and splice() unnecessary in state driven code.

16 min read ES2023 Immutability React & Redux

1. Why mutating array methods are a problem

JavaScript arrays have always had two categories of methods: ones that return a new copy (map(), filter(), slice()), and ones that change the array in place (sort(), reverse(), splice()). The latter not only change the array's content, they often even return the very same array object as their result, which hides the mistake. Writing const sorted = list.sort() does not create a new array, it permanently mutates list itself and merely creates a second reference to it.

In simple scripts this rarely matters. But as soon as an array is referenced from multiple places, for example as part of React state, a Redux store, or a function parameter that was only meant to be read, the mutation becomes a source of bugs. A component state that changes under the hood without triggering a re render is one of the most classic React bugs there is. The new immutable sibling methods solve this problem at the root by offering exactly the same functionality while always returning a new copy.

2. with(): replacing a single element

Array.prototype.with(index, value) replaces the element at position index and returns a brand new array, the original stays untouched. Previously this required a workaround: either array.slice() followed by direct index assignment, or a combination of the spread operator and template trickery. Both are cumbersome and error prone, especially with negative indices, which with() supports as well, just like at().

The practical benefit shows up immediately in typical UI scenarios: a todo list where a single entry gets marked as done, or a form array where one field needs correcting. Instead of manually copying the whole array and setting the index by hand, a single, readable call suffices. This reduces not only lines of code but also the chance of accidentally mutating the original after all.


const todos = ["Groceries", "Laundry", "Cleaning"];

// Old: manual copy required
const updatedOld = [...todos];
updatedOld[1] = "Laundry (done)";

// New: with() directly returns a new, changed copy
const updated = todos.with(1, "Laundry (done)");

console.log(todos);   // ["Groceries", "Laundry", "Cleaning"] -- unchanged
console.log(updated); // ["Groceries", "Laundry (done)", "Cleaning"]

// Also works with negative indices like at()
const lastReplaced = todos.with(-1, "Cooking");
console.log(lastReplaced); // ["Groceries", "Laundry", "Cooking"]

3. toSorted() and toReversed(): sorting without side effects

toSorted() and toReversed() behave exactly like their mutating counterparts sort() and reverse(), including the identical comparator function for toSorted(), but each returns a new array instance. The original keeps its original order. Anyone who has ever hit the bug where a list shared across multiple places suddenly appeared in the wrong order because sort() was called somewhere without a copy knows how subtle this mistake can be.

Especially in components that sort data only for display, without changing the underlying data source, toSorted() is the right choice. A sort dropdown in a product list, for instance, should reorder the view but not scramble the order in the actual data model, which might still need its original order elsewhere, for example for an undo mechanism or a second view.


const prices = [42, 7, 19, 3, 88];

const sorted = prices.toSorted((a, b) => a - b);
const reversed = prices.toReversed();

console.log(prices);   // [42, 7, 19, 3, 88] -- unchanged
console.log(sorted);   // [3, 7, 19, 42, 88]
console.log(reversed); // [88, 3, 19, 7, 42]

// Handy inside a React reducer function:
function sortReducer(state, action) {
  switch (action.type) {
    case "SORT_ASC":
      return { ...state, items: state.items.toSorted((a, b) => a.value - b.value) };
    default:
      return state;
  }
}

4. toSpliced(): inserting and removing without mutation

splice() is probably the most versatile but also the most dangerous array method: it can remove, insert and replace elements at the same time, but it always mutates the array and, to add to the confusion, returns the removed elements instead of the changed array. toSpliced() takes the exact same signature, start index, delete count, elements to insert, but returns the complete new array copy and leaves the original alone.

This lets you express complex list operations, such as inserting a new entry at a specific position or removing a range, in a single declarative line, without any spread gymnastics using slice(0, i) and slice(i + 1). Especially in Redux reducers or Zustand stores, where any mutation of the state violates the basic rules, toSpliced() is a direct replacement for error prone manual spread combinations.


const cart = ["Apple", "Pear", "Cherry", "Grape"];

// Insert one element at position 2, delete nothing
const inserted = cart.toSpliced(2, 0, "Plum");
console.log(inserted); // ["Apple", "Pear", "Plum", "Cherry", "Grape"]

// Remove two elements starting at position 1
const removed = cart.toSpliced(1, 2);
console.log(removed); // ["Apple", "Grape"]

console.log(cart); // ["Apple", "Pear", "Cherry", "Grape"] -- unchanged

5. Practical benefit in React and Redux style state

React and Redux are built on the principle that state changes must always produce new object references, so change detection and re renders work reliably. Before the new methods, the usual workaround was calling mutating array methods on a copy created via spread first: [...state.items].sort(...). That works, but it is an extra mental step that has to be applied correctly on every single update, and a forgotten spread is a classic, hard to find bug.

With toSorted(), toReversed(), with() and toSpliced(), that intermediate step disappears entirely because immutability is built directly into the method. This not only reduces the chance of typos but also makes reducer functions more readable, because the intent is directly visible from the method name: a toSorted() call immediately signals that a sorted copy is being created intentionally, without having to check whether a spread precedes it.

6. Structural sharing and performance considerations

A common misconception is that immutable operations are automatically expensive because a copy is always created. For the new array methods that is only partly true: a new array with new internal references is indeed created, but the contained objects themselves are not deep copied, only their references are carried over. Sorting an array of a thousand objects therefore does not cost copying a thousand objects, only allocating a new array holding the same thousand references in a new order.

For very large arrays that get completely reallocated on every single state update, the copy can still be measurable, especially for lists with tens of thousands of entries updated multiple times per second. In those cases it is worth looking at specialized persistent data structure libraries with tree level structural sharing. For the vast majority of UI use cases, forms, lists with a few hundred entries, sort dropdowns, the overhead of the new methods is negligible and the readability gain clearly outweighs it.

7. Common pitfalls and migration strategy

One subtle gotcha: toSorted() with a comparator function sorts exactly like sort(), meaning numbers are compared lexicographically as strings by default without a comparator. [10, 2, 33].toSorted() therefore returns [10, 2, 33] not numerically ascending but lexicographically, which is often not the desired result for numeric arrays. The comparator (a, b) => a - b is therefore still required, the new methods only change the mutation behavior, not the sorting logic itself.

For migrating existing codebases, a stepwise approach is recommended: first identify every place where sort(), reverse() or splice() is called on state objects, typically via a code search or an ESLint rule such as no-param-reassign combined with a custom rule against mutating methods on props or state. Those places can then be replaced directly with the immutable counterparts without changing the surrounding logic, since parameters and return value behave identically aside from the mutation.

8. Browser support and polyfill strategy

The four methods are part of the ES2023 standard and have been available since late 2023 in all current versions of Chrome, Firefox, Safari and Node.js from version 20 onward. For projects that need to support older browsers or LTS Node versions, the core-js polyfill package retrofits the methods transparently without requiring any application code changes. A feature check such as Array.prototype.toSorted === undefined before loading the polyfill avoids unnecessary overhead in modern environments.

TypeScript projects additionally need an up to date lib target in tsconfig.json, at least ES2023, so the compiler recognizes the new methods without type errors. Anyone compiling TypeScript with an older target like ES2020 while the runtime is still ES2023 capable can set the lib option independently of the compilation target and thus use the new type definitions without changing the output syntax.

9. Comparison table: mutating vs. immutable

The table below puts every mutating method directly next to its immutable counterpart and shows the return value to expect in each case. Important detail: the parameters are identical for every pair, only the mutation behavior and the return value change, so migration is usually a plain search and replace of the method name.

As a rule of thumb: whenever an array result ends up in state, a Redux store, a React context, or the return value of a pure function, the immutable variants are the safer choice. Only in performance critical, purely local loops without shared references is the mutating variant still justified, because it saves one array allocation.

Mutating method Immutable method Return value Original
sort() toSorted() New sorted array Unchanged
reverse() toReversed() New reversed array Unchanged
splice() toSpliced() New array with the change Unchanged
Index assignment arr[i] = with(i, value) New array with replaced element Unchanged

Mironsoft

Modern browser APIs, performance, and maintainable JavaScript

JavaScript that holds up in the real browser, not just in the tutorial?

We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.

Code Review

Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.

Performance Optimization

Improving bundle size, load time, and runtime performance with modern APIs.

Modernization

Deliberately introducing native browser APIs instead of heavy libraries.

10. Summary

Immutable array methods at a glance

with()

Replaces one element by index, returns a new array copy, supports negative indices like at().

toSorted() / toReversed()

Sort or reverse without changing the original, same comparator signature as sort().

toSpliced()

Insert, remove and replace in one call, returns the new array instead of the removed elements.

Use case

React state, Redux reducers and anywhere shared array references need protection from accidental mutation.

11. FAQ: Immutable array methods at a glance

1What is the main difference between sort() and toSorted()?
sort() sorts the array in place and returns the same reference, toSorted() leaves the original untouched and returns a completely new array instance with the same sorting logic.
2Can I still use a custom comparator function with toSorted()?
Yes, toSorted() accepts the exact same optional comparator function as sort(). Without a comparator, sorting is still lexicographic as strings, even for number arrays.
3Do with() and toSpliced() also copy the contained objects?
No, only a shallow copy happens. The new array structure contains the same object references as the original, only the array shell itself is new. Changes to the contained objects therefore still affect both arrays.
4From which Node.js version are the methods available?
From Node.js 20 onward, with(), toSorted(), toReversed() and toSpliced() are natively available without flags. Older Node LTS versions need a polyfill such as core-js.
5Do I have to replace all old sort() calls in my code now?
Not necessarily. In purely local, short lived variables without shared references, sort() is still perfectly fine. It only becomes critical when the array is part of shared state or a prop.
6What happens with with() if the index is out of bounds?
with() throws a RangeError if the index is outside the valid range. Unlike a direct index assignment, with() does not allow automatically growing the array beyond its end.
7Are the new methods more performant than the old spread plus mutation combination?
Generally comparable, sometimes slightly faster since the engine can optimize the copy operation internally. The real advantage, however, lies in readability and lower error proneness, not primarily in raw speed.
8Do toSorted() and toReversed() also work on TypedArrays?
Yes, TypedArrays such as Int32Array or Float64Array also have with(), toSorted(), toReversed(), but a toSpliced() variant does not exist there since TypedArrays have a fixed length.
9Can toSpliced() insert multiple elements at once?
Yes, every argument from the third onward is treated as an element to insert, exactly like splice(). arr.toSpliced(1, 0, 'a', 'b', 'c') inserts three elements at position 1.
10Do the new methods replace Immutable.js or Immer in React projects?
For simple array operations they often make specialized libraries unnecessary. For deeply nested object structures, Immer and Immutable.js still provide extra value through structural sharing across object levels, not just at the array level.