toSorted() and Friends: Arrays That Stop Mutating Under You
The oldest trap in JavaScript: sort() sorts in place. Sort the props you received, the array in a parent component, the cached response - and you've mutated shared state that three other places were relying on. I've debugged that exact bug in production more times than I'll admit.
// The old defensive idiom
const sorted = [...items].sort((a, b) => a.price - b.price);
// ES2023: says what it means
const sorted = items.toSorted((a, b) => a.price - b.price);
Four methods arrived together, each a non-mutating twin of a classic offender:
const byPrice = products.toSorted((a, b) => a.price - b.price);
const newest = posts.toReversed();
const trimmed = items.toSpliced(2, 1); // copy with index 2 removed
const updated = rows.with(3, { ...rows[3], done: true }); // copy with index 3 replaced
with() is the sleeper
Updating one item at a known index used to be the ugliest line in state updates:
// before
const next = items.map((item, i) => i === index ? newValue : item);
// after
const next = items.with(index, newValue);
It reads like array indexing, supports negative indices (list.with(-1, x) replaces the last element), and throws RangeError on out-of-bounds instead of silently creating sparse weirdness.
Why this matters beyond taste
- Framework state: React and friends detect changes by reference. Mutating methods keep the same reference, so the UI doesn't update - the single most common "why won't it re-render" cause. The to*() methods return new arrays by construction.
- The spread idiom lies a little: [...arr].sort() works but buries the intent in punctuation, and people routinely forget the copy step under deadline. A method named toSorted can't be mis-written into its mutating cousin.
- Shallow, like everything else: the array is new, the objects inside are shared. For replacing an object's field, combine with spread as in the with() example - or reach for structuredClone when you genuinely need a deep copy.
Support and the memory footnote
All four landed everywhere in 2023: Chrome 110+, Firefox 115+, Safari 16.4+, Node 20+. Each call allocates a new array - irrelevant at UI scale (hundreds of items), worth thinking about in a hot loop over hundreds of thousands, where in-place sort of a local copy you own is still the right tool. Own the array? Mutate freely. Received it from anywhere else? to*() by default.
Honorary mention to their older siblings from the same cleanup effort: at(-1) for the last element and findLast()/findLastIndex() for searching from the end - all universally supported and all quietly deleting helper functions from your utils file.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.