Episode 1 — Fundamentals / 1.21 — Arrays

1.21.c -- Array Operations

In one sentence: JavaScript provides push/pop for stack-like end operations, unshift/shift for beginning operations, concat and the spread operator for merging, and several in-place methods like reverse, fill, and copyWithin -- knowing which methods mutate and which return new arrays is critical.

Navigation: <- 1.21.b -- Accessing Elements . 1.21.d -- Length Property ->


1. push(item) -- add to END

push() appends one or more elements to the end of an array and returns the new length.

const fruits = ["apple", "banana"];

const newLength = fruits.push("cherry");
console.log(fruits);      // ["apple", "banana", "cherry"]
console.log(newLength);   // 3

// Push multiple items at once
fruits.push("date", "elderberry");
console.log(fruits);      // ["apple", "banana", "cherry", "date", "elderberry"]
console.log(fruits.length); // 5

Important: push() mutates the original array and returns the length, not the array.

// Common mistake: assuming push returns the array
const result = [1, 2].push(3);
console.log(result);  // 3 (the new length, NOT the array!)

2. pop() -- remove from END

pop() removes the last element and returns it. If the array is empty, it returns undefined.

const stack = [10, 20, 30, 40];

const removed = stack.pop();
console.log(removed);  // 40
console.log(stack);    // [10, 20, 30]

stack.pop();
console.log(stack);    // [10, 20]

// Pop from empty array
const empty = [];
console.log(empty.pop());  // undefined

3. unshift(item) -- add to BEGINNING

unshift() inserts one or more elements at the beginning and returns the new length.

const queue = ["bob", "carol"];

const len = queue.unshift("alice");
console.log(queue);  // ["alice", "bob", "carol"]
console.log(len);    // 3

// Multiple items
queue.unshift("xander", "yuki");
console.log(queue);  // ["xander", "yuki", "alice", "bob", "carol"]

4. shift() -- remove from BEGINNING

shift() removes the first element and returns it. All remaining elements shift down by one index.

const line = ["first", "second", "third"];

const served = line.shift();
console.log(served);  // "first"
console.log(line);    // ["second", "third"]

line.shift();
console.log(line);    // ["third"]

5. Performance: push/pop O(1) vs unshift/shift O(n)

This is a crucial concept for writing efficient code.

push/pop (END operations):
┌───┬───┬───┬───┐
│ A │ B │ C │ + │  <-- just add/remove at the end
└───┴───┴───┴───┘
No other elements need to move.  Time: O(1)

unshift/shift (BEGINNING operations):
┌───┬───┬───┬───┐
│ + │ A │ B │ C │  <-- every element must shift right
└───┴───┴───┴───┘
All elements re-indexed.  Time: O(n)
OperationTime complexityWhy
push()O(1) amortizedAppends to end; no re-indexing
pop()O(1)Removes from end; no re-indexing
unshift()O(n)All existing elements must shift right
shift()O(n)All remaining elements must shift left
// Performance test (conceptual)
const big = [];

// Fast -- 100,000 push operations
for (let i = 0; i < 100000; i++) {
  big.push(i);   // O(1) each -- total O(n)
}

// Slow -- 100,000 unshift operations
const slow = [];
for (let i = 0; i < 100000; i++) {
  slow.unshift(i);  // O(i) each -- total O(n^2)!
}

Takeaway: Prefer push/pop when possible. Use unshift/shift only when you specifically need beginning operations.


6. concat() -- merging arrays

concat() merges two or more arrays into a new array. It does not mutate the originals.

const a = [1, 2, 3];
const b = [4, 5, 6];

const merged = a.concat(b);
console.log(merged);  // [1, 2, 3, 4, 5, 6]
console.log(a);       // [1, 2, 3]  (unchanged)
console.log(b);       // [4, 5, 6]  (unchanged)

// Multiple arrays
const c = [7, 8];
const all = a.concat(b, c);
console.log(all);     // [1, 2, 3, 4, 5, 6, 7, 8]

// Concat with individual values
const extended = a.concat(4, 5);
console.log(extended);  // [1, 2, 3, 4, 5]

// Concat does NOT flatten nested arrays (only one level)
const nested = [1, 2].concat([3, [4, 5]]);
console.log(nested);  // [1, 2, 3, [4, 5]]

7. Spread operator for merging: [...arr1, ...arr2]

The spread operator ... is the modern, more flexible alternative to concat().

const a = [1, 2, 3];
const b = [4, 5, 6];

// Basic merge
const merged = [...a, ...b];
console.log(merged);  // [1, 2, 3, 4, 5, 6]

// Add elements in between
const withMiddle = [...a, 99, ...b];
console.log(withMiddle);  // [1, 2, 3, 99, 4, 5, 6]

// Prepend/append individual items
const prefixed = [0, ...a];
console.log(prefixed);  // [0, 1, 2, 3]

const suffixed = [...a, 4];
console.log(suffixed);  // [1, 2, 3, 4]

// Clone an array (shallow copy)
const clone = [...a];
console.log(clone);       // [1, 2, 3]
console.log(clone === a); // false (different reference)

Spread vs concat comparison:

Featureconcat()Spread [...]
Syntaxa.concat(b)[...a, ...b]
Insert betweenAwkward[...a, x, ...b]
Works with any iterableNo (arrays/values only)Yes (strings, Sets, etc.)
Creates new arrayYesYes
// Spread works with any iterable
const fromString = [..."hello"];   // ["h", "e", "l", "l", "o"]
const fromSet = [...new Set([1, 2, 2, 3])]; // [1, 2, 3]

8. reverse() -- reverses IN PLACE (mutates!)

reverse() reverses the order of elements in the original array and returns the same (now reversed) array.

const nums = [1, 2, 3, 4, 5];

const result = nums.reverse();
console.log(nums);          // [5, 4, 3, 2, 1]  (mutated!)
console.log(result);        // [5, 4, 3, 2, 1]
console.log(result === nums); // true (same reference)

If you need a reversed copy without mutating:

const original = [1, 2, 3, 4, 5];

// Method 1: spread + reverse
const reversed1 = [...original].reverse();

// Method 2: slice + reverse
const reversed2 = original.slice().reverse();

// Method 3: toReversed() (ES2023 -- non-mutating)
const reversed3 = original.toReversed();

console.log(original);  // [1, 2, 3, 4, 5]  (unchanged)
console.log(reversed1); // [5, 4, 3, 2, 1]

9. fill() -- fill with a static value

fill() replaces all (or some) elements with a static value. It mutates the array.

// Fill entire array
const arr = [1, 2, 3, 4, 5];
arr.fill(0);
console.log(arr);  // [0, 0, 0, 0, 0]

// Fill with start index
const arr2 = [1, 2, 3, 4, 5];
arr2.fill(9, 2);         // fill with 9 from index 2 to end
console.log(arr2);       // [1, 2, 9, 9, 9]

// Fill with start and end index
const arr3 = [1, 2, 3, 4, 5];
arr3.fill(0, 1, 3);      // fill with 0 from index 1 to 3 (exclusive)
console.log(arr3);       // [1, 0, 0, 4, 5]

// Common: create array of n zeros
const zeros = new Array(5).fill(0);
console.log(zeros);      // [0, 0, 0, 0, 0]

// Warning: fill with objects shares the SAME reference!
const grid = new Array(3).fill([]);
grid[0].push("x");
console.log(grid);       // [["x"], ["x"], ["x"]]  -- all three are the same array!

// Correct way: use Array.from
const gridCorrect = Array.from({ length: 3 }, () => []);
gridCorrect[0].push("x");
console.log(gridCorrect); // [["x"], [], []]  -- independent arrays

10. copyWithin() -- copy part of array to another location

copyWithin(target, start, end) copies a section of the array to another position within the same array. It mutates in place and does not change the length.

const arr = [1, 2, 3, 4, 5];

// copyWithin(target, start, end)
// Copy elements from index 3 to end, paste at index 0
arr.copyWithin(0, 3);
console.log(arr);  // [4, 5, 3, 4, 5]

// Another example
const arr2 = [1, 2, 3, 4, 5];
arr2.copyWithin(1, 3, 5);  // copy index 3-4, paste at index 1
console.log(arr2);          // [1, 4, 5, 4, 5]

// Negative indices
const arr3 = [1, 2, 3, 4, 5];
arr3.copyWithin(-2);  // copy from 0, paste at index -2 (3)
console.log(arr3);    // [1, 2, 3, 1, 2]

When is this useful? Rare in everyday code; mainly for performance-critical scenarios like typed arrays or buffer manipulation.


11. Mutating vs non-mutating methods (summary table)

This is one of the most important distinctions in JavaScript arrays.

MethodMutates original?Returns
push()YesNew length
pop()YesRemoved element
unshift()YesNew length
shift()YesRemoved element
reverse()YesSame (reversed) array
fill()YesSame (filled) array
copyWithin()YesSame (modified) array
concat()NoNew merged array
[...a, ...b]NoNew merged array
slice()NoNew sub-array
toReversed() (ES2023)NoNew reversed array

Rule of thumb:

  • Methods that add/remove/reorder in place are mutating.
  • Methods that return a new array are non-mutating.
  • When in doubt, check: does the original array change?
// Mutating -- changes original
const a = [3, 1, 2];
a.reverse();
console.log(a);  // [2, 1, 3] -- a is changed

// Non-mutating -- original preserved
const b = [1, 2, 3];
const c = b.concat([4, 5]);
console.log(b);  // [1, 2, 3] -- b unchanged
console.log(c);  // [1, 2, 3, 4, 5] -- new array

12. Real examples: stack and queue

Stack (LIFO -- Last In, First Out)

A stack uses push and pop -- like a stack of plates.

const stack = [];

// Push items onto the stack
stack.push("page-1");
stack.push("page-2");
stack.push("page-3");
console.log(stack);  // ["page-1", "page-2", "page-3"]

// Pop from the stack (most recent first)
const current = stack.pop();
console.log(current);  // "page-3"
console.log(stack);    // ["page-1", "page-2"]

// Browser history: back button pops the stack
stack.pop();           // "page-2"
console.log(stack);    // ["page-1"]

Queue (FIFO -- First In, First Out)

A queue uses push (add to end) and shift (remove from front) -- like a line at a store.

const queue = [];

// Enqueue -- customers join at the end
queue.push("Alice");
queue.push("Bob");
queue.push("Carol");
console.log(queue);  // ["Alice", "Bob", "Carol"]

// Dequeue -- serve from the front
const next = queue.shift();
console.log(next);   // "Alice"
console.log(queue);  // ["Bob", "Carol"]

queue.shift();       // "Bob"
console.log(queue);  // ["Carol"]

Undo/Redo system

const history = [];
let current = "initial";

function doAction(action) {
  history.push(current);  // save current state
  current = action;
  console.log("Action:", current, "| History:", history);
}

function undo() {
  if (history.length === 0) {
    console.log("Nothing to undo");
    return;
  }
  current = history.pop();
  console.log("Undone to:", current, "| History:", history);
}

doAction("bold text");      // Action: bold text | History: ["initial"]
doAction("italic text");    // Action: italic text | History: ["initial", "bold text"]
undo();                     // Undone to: bold text | History: ["initial"]
undo();                     // Undone to: initial | History: []
undo();                     // Nothing to undo

Key takeaways

  1. push/pop operate on the end -- O(1), efficient, use for stacks.
  2. unshift/shift operate on the beginning -- O(n), slower, use for queues.
  3. concat() and spread [...] merge arrays without mutating the originals.
  4. reverse() mutates in place -- copy first if you need the original intact.
  5. fill() is great for initializing, but beware of shared references with objects.
  6. Mutating vs non-mutating is the most important mental model for array methods.
  7. Stacks use push/pop (LIFO); queues use push/shift (FIFO).

Explain-It Challenge

Explain without notes:

  1. Why is unshift() slower than push() for large arrays?
  2. You call const result = arr.push(5). What is result -- the array or something else?
  3. What happens if you call arr.reverse() without making a copy first -- and why might this cause bugs?
  4. Show how to merge three arrays a, b, c into one using spread syntax with an extra element between b and c.

Navigation: <- 1.21.b -- Accessing Elements . 1.21.d -- Length Property ->