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.
Table of Contents
- 1. Why mutating array methods are a problem
- 2. with(): replacing a single element
- 3. toSorted() and toReversed(): sorting without side effects
- 4. toSpliced(): inserting and removing without mutation
- 5. Practical benefit in React and Redux style state
- 6. Structural sharing and performance considerations
- 7. Common pitfalls and migration strategy
- 8. Browser support and polyfill strategy
- 9. Comparison table: mutating vs. immutable
- 10. Summary
- 11. FAQ
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.