JavaScript · Arrays & Iteration

Mutating vs Non-Mutating Array Methods in JavaScript — Immutability Done Right

6 min read Updated 2026-06-18

Practice Mutating vs Non-Mutating interview questions

Why mutation matters

One of the most common sources of subtle bugs in JavaScript is accidental mutation. Arrays are reference types: when you pass an array to a function or assign it to another variable, both names point to the same array. If one place mutates it, the change is visible everywhere — often far from where you'd expect. Knowing exactly which array methods mutate in place versus which return a new array is essential for writing predictable code, and it's the foundation of immutable state in frameworks like React and Redux.

This guide draws the line clearly and shows the patterns for working immutably.

The reference-sharing trap

First, the core problem. Two variables can reference one array:

const original = [1, 2, 3]
const alias = original      // NOT a copy — same array
alias.push(4)
original                    // [1, 2, 3, 4] original changed too

This also bites function arguments: a function that mutates its array parameter silently changes the caller's data. The fix is either to copy before mutating or to use non-mutating methods that return new arrays.

The mutating methods

These methods change the array in place and (usually) return something other than a fresh array. Memorize this list — it's the source of most mutation surprises:

arr.push(x)      // add to end, returns new length
arr.pop()        // remove from end, returns the element
arr.shift()      // remove from front, returns the element
arr.unshift(x)   // add to front, returns new length
arr.splice(i, n) // remove/insert at index, returns removed items
arr.sort(fn)     // sorts IN PLACE
arr.reverse()    // reverses IN PLACE
arr.fill(v)      // overwrites a range in place
arr.copyWithin() // copies a range in place

The two that catch people off guard are sort and reverse — they look like they produce a new ordering but actually mutate the original and return the same reference.

const a = [3, 1, 2]
const b = a.sort((x, y) => x - y)
b === a      // true a is now sorted too, b is just the same array

The non-mutating methods

These methods leave the original untouched and return a new array (or a value). They are safe to use freely without worrying about side effects:

arr.map(fn)        // new transformed array
arr.filter(fn)     // new filtered array
arr.slice(s, e)    // new sub-array (a shallow copy)
arr.concat(other)  // new combined array
arr.flat()         // new flattened array
arr.flatMap(fn)    // new mapped+flattened array
[...arr]           // spread — new shallow copy

slice with no arguments (arr.slice()) and spread ([...arr]) are the two idiomatic ways to make a quick shallow copy of an array.

The new immutable twins (ES2023)

Recognizing the danger of sort/reverse/splice, ES2023 added non-mutating counterparts that return new arrays, plus a with method for immutable index replacement:

const a = [3, 1, 2]
a.toSorted((x, y) => x - y)   // new sorted array a unchanged
a.toReversed()                // new reversed array
a.toSpliced(1, 1)             // new array with item removed
a.with(0, 99)                 // new array with index 0 replaced

These replace verbose patterns like [...a].sort(...) and the old [...a.slice(0, i), val, ...a.slice(i + 1)] dance. Prefer them wherever immutability matters.

Immutable update patterns

When you must "change" an array without mutating it, build a new one around the change:

const arr = [1, 2, 3]

const added    = [...arr, 4]                          // append
const prepended = [0, ...arr]                         // prepend
const removed  = arr.filter(x => x !== 2)             // remove by value
const removedAt = [...arr.slice(0, 1), ...arr.slice(2)] // remove by index
const updated  = arr.map(x => x === 2 ? 20 : x)       // replace by condition
const replaced = arr.with(1, 20)                      // replace by index (ES2023)

These patterns are the bread and butter of state management. Each produces a brand-new array, leaving the original intact so previous references remain stable.

Why immutability helps frameworks

React and similar libraries detect changes by reference comparison (prevState === nextState). If you mutate an array in place, the reference doesn't change, so the framework can't tell anything happened and won't re-render.

// React won't re-render — same reference
state.items.push(newItem)
setState(state)

// new array reference signals a change
setState({ ...state, items: [...state.items, newItem] })

This is the reason immutable array methods matter so much in modern front-end work. Mutation breaks change detection; new references enable it.

Shallow vs deep copies

A critical caveat: spread, slice, and concat all make shallow copies. The new array is independent, but nested objects/arrays inside it are still shared by reference.

const original = [{ id: 1 }, { id: 2 }]
const copy = [...original]
copy[0].id = 99
original[0].id   // 99 inner object was shared

To update nested data immutably, copy the path you're changing (e.g. map the array and spread the object you touch), or use structuredClone for a full deep copy of plain data.

const updated = original.map(o => o.id === 1 ? { ...o, id: 99 } : o)  // new objects

A practical rule of thumb

When in doubt, treat arrays as immutable: prefer map/filter/slice/spread and the to*/with methods, and reserve mutating methods for local arrays you fully own and never share. If you do mutate, do it on a copy:

const sorted = [...arr].sort((a, b) => a - b)   // original safe

This single habit eliminates a large fraction of "why did this value change?" debugging sessions.

Key takeaways

  • Arrays are references; assigning or passing one shares it, so in-place mutation leaks everywhere.
  • Mutating: push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin — note sort/reverse surprise people.
  • Non-mutating: map, filter, slice, concat, flat, flatMap, spread — safe, return new arrays.
  • ES2023 adds immutable twins: toSorted, toReversed, toSpliced, and with.
  • Frameworks rely on new references for change detection — mutation breaks re-rendering.
  • Spread/slice are shallow; deep-update by copying the changed path or using structuredClone.

Internalize which methods mutate and default to immutable patterns, and an entire category of hard-to-trace bugs simply disappears.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.