DEV Community

Cover image for Your `.sort()` is mutating state. JavaScript finally gave you the fix.
Parsa Jiravand
Parsa Jiravand

Posted on

Your `.sort()` is mutating state. JavaScript finally gave you the fix.

Here is a bug I have shipped before. Maybe you have too:

const [tasks, setTasks] = useState(initialTasks);

function sortByPriority() {
  const sorted = tasks.sort((a, b) => b.priority - a.priority);
  setTasks(sorted);
}
Enter fullscreen mode Exit fullscreen mode

This looks correct. It doesn't work correctly. Array.prototype.sort mutates the array in place and returns the same reference. sorted and tasks point to the same array. React sees the same reference it had before and skips the re-render. The UI doesn't update, or it updates inconsistently — it depends on whether anything else triggered a render cycle.

The fix you've been writing for years:

const sorted = [...tasks].sort((a, b) => b.priority - a.priority);
Enter fullscreen mode Exit fullscreen mode

The defensive spread creates a new array, sort mutates that one, and you get a fresh reference React can track. It works. It's also cargo-cult boilerplate you have to remember to write, and it's easy to forget under pressure.

ES2023 added four methods that make the spread unnecessary.

toSorted and toReversed

Array.prototype.toSorted is the non-mutating version of sort. It returns a new sorted array and leaves the original unchanged:

const tasks = [
  { id: 1, priority: 2, title: 'Review PR' },
  { id: 2, priority: 5, title: 'Fix regression' },
  { id: 3, priority: 1, title: 'Update docs' },
];

const sorted = tasks.toSorted((a, b) => b.priority - a.priority);

console.log(sorted[0].title); // 'Fix regression'
console.log(tasks[0].title);  // 'Review PR' — untouched
Enter fullscreen mode Exit fullscreen mode

The API is identical to sort. Any comparator that works with sort works with toSorted. The only difference is where the result lives.

toReversed is the same story for reverse:

const steps = ['write test', 'make it pass', 'refactor'];
const reversed = steps.toReversed();

console.log(reversed); // ['refactor', 'make it pass', 'write test']
console.log(steps[0]); // 'write test' — original intact
Enter fullscreen mode Exit fullscreen mode

Array.prototype.reverse is one of the sneakier mutators because it returns this — the same array — making const rev = arr.reverse() look like it produced a copy when it didn't.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

toSpliced

splice is the most powerful mutator in the array API. It inserts, removes, and replaces elements in place and returns the removed items — not the resulting array. toSpliced flips that contract: it returns the new array with the changes applied and leaves the original unchanged.

const items = ['a', 'b', 'c', 'd'];

// Remove 1 item at index 1, insert 'x' and 'y'
const updated = items.toSpliced(1, 1, 'x', 'y');

console.log(updated); // ['a', 'x', 'y', 'c', 'd']
console.log(items);   // ['a', 'b', 'c', 'd'] — untouched
Enter fullscreen mode Exit fullscreen mode

The argument signature mirrors splice exactly: toSpliced(start, deleteCount, ...items). For insertions at a specific position, deletions from a slice, or multi-item replacements — it's the same mental model, no mutation.

with — replacing one item by index

This one is new in a different way. There was no mutable counterpart to replace because direct assignment (arr[i] = value) already did it. What didn't exist was a way to replace one item at a specific index without touching the original.

The workaround was either map:

const updated = items.map((item, i) => i === targetIndex ? newValue : item);
Enter fullscreen mode Exit fullscreen mode

Or spread-and-reassign:

const updated = [...items.slice(0, i), newValue, ...items.slice(i + 1)];
Enter fullscreen mode Exit fullscreen mode

Both communicate the intent poorly. with is the named operation:

const items = ['a', 'b', 'c', 'd'];
const updated = items.with(1, 'z');

console.log(updated); // ['a', 'z', 'c', 'd']
console.log(items);   // ['a', 'b', 'c', 'd']
Enter fullscreen mode Exit fullscreen mode

It also supports negative indices — items.with(-1, 'z') replaces the last element — matching the same convention as Array.prototype.at.

Why this matters for React (and Zustand, and Immer, and anything with immutable state)

State in React must be treated as immutable. That rule is not a preference — React uses object identity to detect changes, so mutating an existing array and passing the same reference produces no re-render, regardless of whether the contents changed. The same applies to Vue's reactivity system, Zustand stores, Redux reducers, and any library that compares references before scheduling an update.

The immutable array methods give you the correct default:

// Before
setTasks(prev => [...prev].sort((a, b) => b.priority - a.priority));

// After
setTasks(prev => prev.toSorted((a, b) => b.priority - a.priority));
Enter fullscreen mode Exit fullscreen mode
// Before — delete an item
setItems(prev => prev.filter((_, i) => i !== indexToRemove));

// After — splice idiom
setItems(prev => prev.toSpliced(indexToRemove, 1));
Enter fullscreen mode Exit fullscreen mode
// Before — update one item
setItems(prev => prev.map((item, i) => i === idx ? { ...item, done: true } : item));

// After
setItems(prev => prev.with(idx, { ...prev[idx], done: true }));
Enter fullscreen mode Exit fullscreen mode

Less ceremony, clearer intent, and no defensive spread to forget.

Browser support

All four methods are Baseline 2023: Chrome 110, Firefox 115, Safari 16, Node.js 20. That's roughly every browser released in the last two years. No polyfill needed if your browserslist target is reasonably current; a one-liner shim covers the rest.

🧠 Test yourself

Think it clicked? Take the 6-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The takeaway

Search your codebase for ].sort( and [... preceding a sort. Every [...arr].sort(fn) can become arr.toSorted(fn). Every [...arr].reverse() can become arr.toReversed(). Every arr.map((item, i) => i === idx ? … : item) that replaces by index can become arr.with(idx, …).

The defensive spread was never the right abstraction — it was a workaround for methods that should have had immutable variants from the start. They do now. Use them.


Thanks for reading! Let's stay connected:

Top comments (0)