Adding and Removing

Mental model: Work at the end of an array when you can: push and pop touch one slot, shift and unshift move every slot.

Level: beginner · about 10 minutes

const stack = ['a', 'b'];

console.log(stack.push('c'));    // 3, the new length
console.log(stack.pop());        // 'c', the removed element
console.log(stack.unshift('z')); // 3, the new length
console.log(stack.shift());      // 'z', the removed element
console.log(stack);              // ['a', 'b'], back where we started

All four of these change the array in place and return something different.

These are mutating methods: they modify the array you called them on rather than returning a new one. That is fine when you own the array, and a source of confusing bugs when you were handed it by someone else. Note the asymmetry in return values: the adders give you the new length, the removers give you the element.

MethodDoes whatReturnsCost
push(x)adds to the endnew lengthO(1)
pop()removes from the endthe elementO(1)
unshift(x)adds to the frontnew lengthO(n)
shift()removes from the frontthe elementO(n)
splice(i, n, ...add)removes and inserts anywherearray of removed itemsO(n)

A stack is push plus pop

const history = [];

history.push('opened file');
history.push('typed a line');
history.push('deleted a line');

console.log(history.pop()); // 'deleted a line', most recent first
console.log(history.pop()); // 'typed a line'
console.log(history);       // ['opened file']

Last in, first out. Undo history, call stacks, bracket matching.

A queue is push plus shift

const jobs = [];

jobs.push('render');
jobs.push('upload');
jobs.push('email');

console.log(jobs.shift()); // 'render', oldest first
console.log(jobs.shift()); // 'upload'
console.log(jobs);         // ['email']

First in, first out. Print jobs, task runners, breadth-first search.

Queue with shift (fine, and simple)

const q = [1, 2, 3];
while (q.length) {
  handle(q.shift());   // O(n) per call
}

Queue with a moving head (fast)

const q = [1, 2, 3];
let head = 0;
while (head < q.length) {
  handle(q[head]);     // O(1) per call
  head += 1;
}

Reach for the second form only when the queue is large or hot. Readability first, then measure, then optimise the thing you measured.

splice does everything, which is why it is confusing

const days = ['mon', 'tue', 'wed', 'thu'];

const removed = days.splice(1, 2);        // from index 1, remove 2
console.log(removed);                     // ['tue', 'wed']
console.log(days);                        // ['mon', 'thu']

days.splice(1, 0, 'tue', 'wed');          // remove nothing, insert two
console.log(days);                        // ['mon', 'tue', 'wed', 'thu']
  • splice(i) removes everything from i onward.
  • splice(i, 0, x) inserts x at i without removing anything.
  • splice(i, 1, x) replaces the element at i.
  • splice(-1, 1) removes the last element, like pop.
const nums = [1, 2, 2, 3];

for (let i = 0; i < nums.length; i++) {
  if (nums[i] === 2) nums.splice(i, 1);   // skips the second 2
}
console.log(nums);                        // [1, 2, 3], one 2 survived

console.log([1, 2, 2, 3].filter((n) => n !== 2)); // [1, 3], no index games

The classic off-by-one, and the two-character fix.

const a = ['a', 'b', 'c', 'd'];
const out = a.splice(1, 2, 'x');
console.log(out, a);

splice returns the array of what it removed and mutates the original to hold the result. Two elements came out (b and c), one went in (x), so the array shrinks by one. This return value is the single most misremembered thing about splice.

Try it yourself

Stack against queue, same input

const input = ['first', 'second', 'third'];

const stack = [...input];
const queue = [...input];

console.log('stack order:');
while (stack.length) console.log(' ', stack.pop());

console.log('queue order:');
while (queue.length) console.log(' ', queue.shift());

console.log('the original is untouched:', input);

Swap shift for pop in the queue and watch the order flip. Then add a peek that reads without removing.

Exercises

A log that forgets

Write createLog(limit) returning an object with add(entry) and entries(). add appends and returns the current number of entries. Once there are more than limit entries, the oldest ones drop off the front. entries() returns a copy, oldest first, so callers cannot mutate your internal array.

Check yourself

What does this log?
4 [1, 2, 3, 4] — push returns the new length, not the array and not the element. That is why chaining arr.push(x).push(y) fails: you would be calling push on a number.
You process a 200,000 item queue with while (q.length) handle(q.shift()). What is the total cost?
O(n squared), because each shift reindexes the rest — Each shift has to move every remaining element down one index, so n shifts do roughly n squared work. Keeping a head pointer, or using pop when order does not matter, brings it back to O(n).
Which call inserts "x" at index 2 without removing anything?
arr.splice(2, 0, "x") — The second argument is the delete count, so 0 means pure insertion. With 1 you would replace the element at index 2, and slice never inserts anything at all.

Common mistakes

  • Expecting push to return the array, then trying to chain it.
  • Splicing inside a forward for loop, which skips the element after each removal.
  • Reaching for shift in a hot loop over a large array.

Takeaways

  • push/pop work at the cheap end; shift/unshift reindex everything.
  • Adders return the new length; removers return the removed element.
  • splice returns what it removed and mutates in place.
  • Removing while looping forward skips elements. Filter into a new array instead.