Skip to content
Chapter 3Lesson 2

Arrays and the non-mutating update

The JavaScript array surface a senior reaches for, safe indexing under strict TypeScript and the ES2023 non-mutating methods that keep reference-comparing systems like React re-rendering.

Most developers write some version of this React component at least once. The list of invoices renders, the “sort by amount” button is wired up, the click handler calls .sort on the state array, and clicking the button does nothing.

const InvoiceList = () => {
const [invoices, setInvoices] = useState(initialInvoices);
const sortByAmount = () => {
invoices.sort((a, b) => a.amountCents - b.amountCents);
setInvoices(invoices);
// The list doesn't re-order. React skipped the re-render.
};
return <ul>{invoices.map((i) => <li key={i.id}>{i.amountCents}</li>)}</ul>;
};

The click fires, the sort runs, and the underlying array really is reordered, yet the component stays as it was on screen. The reason is that React’s reconciler compared the new invoices to the old invoices, saw the same reference, and skipped the re-render. .sort() mutated the array in place, so the reference never changed. The fix isn’t a defensive copy like [...invoices].sort(...). It’s to use the ES2023 non-mutating twin instead: invoices.toSorted(...) produces a new array with a new reference, so the render lands.

You’ll meet React properly in a later unit, so take this snippet on faith for now. What matters here isn’t the React mechanics but the class of bug. Any system that compares references and skips re-deriving when they match will miss an in-place mutation the same way, and most of the tools you’ll use work like that: Zustand, TanStack Query’s cache, Server Action serialization, every memoization layer. This lesson covers the array operations that stay safe inside that kind of code: index reads that respect TypeScript’s strictness, .at() for positional access, the four non-mutating update methods that replace the mutating originals, and the rule for when mutating is still the right choice.

The course pins noUncheckedIndexedAccess , so reading by index gives you T | undefined rather than T. The rule that applied to bracket reads on objects in the previous lesson applies to array indices here: TypeScript can’t prove the slot is populated, so it forces you to handle the empty case.

const amounts: number[] = [4900, 1200];
const first = amounts[0];
const firstOrZero = amounts[0] ?? 0;

If you try to use first as a number without handling the undefined, you’ll see the error 'amounts[0]' is possibly 'undefined'. That message is the strictness doing its job, not a misconfiguration to chase down.

Two approaches close the gap. The first is ?? fallback, for when a sensible default exists. const first = amounts[0] ?? 0 returns the element type, so there’s no undefined to thread through callers. The ?? operator, covered earlier in the lesson on flat control flow, returns the right-hand side only on null or undefined, never on 0 or '', which is exactly what you want here.

The second is to narrow through a temporary binding, for when no default exists and you want to exit early:

const first = amounts[0];
if (first === undefined) return;
// first is `number` from here on — narrowed by the guard

The shape that doesn’t work is the one many people reach for instinctively:

if (amounts.length > 0) {
const total = amounts[0] * 2; // TS error: amounts[0] is possibly 'undefined'
}

TypeScript can’t prove that a length check implies index 0 is populated, because sparse arrays exist and the type system doesn’t model them. The clean form is always the temporary binding: read once into a const, guard the undefined, then use the narrowed name below.

.at() is the ES2022 method for positional access that handles negative indices natively. arr.at(0) is the first element, arr.at(-1) is the last, and arr.at(-2) is the second-to-last. The return type is T | undefined, the same as bracket access under strict TS.

const amounts = [4900, 1200, 9900, 3300];
const first = amounts.at(0);
const last = amounts.at(-1);
const secondToLast = amounts.at(-2);

The negative index is what .at() adds. For positive indices it behaves identically to bracket access, so write whichever reads cleaner at the call site. For the last element, though, prefer .at(-1) over arr[arr.length - 1]: it’s shorter, has no off-by-one math, and reads as the intent (“the last one”) rather than as arithmetic.

Reshape: mutate locally, replace when shared

Section titled “Reshape: mutate locally, replace when shared”

The rule that follows is about ownership, not about immutability as a philosophy. Whether you may mutate an array depends on who else can see it.

When the array is owned by the function, declared inside it and never escaping to a caller that holds a reference, mutating methods are fine. .push, .pop, .sort, and .splice all earn their place. The mutation can’t surprise anyone, because nobody outside the function can see the array mid-construction.

When the array is shared, held in React state, passed in as props, sitting in a Map, or returned upward to a caller that’s holding the reference, mutating in place is a bug. The caller’s reference still points to the same array, so any system that compares references sees no change and skips the re-derive. React’s reconciler is the canonical case; Zustand, Server Action serialization, and TanStack Query’s cache do the same kind of check. The fix is the non-mutating twin: build a new array and hand back the new reference.

Four pairs cover the whole pattern. They’re worth memorizing.

| Mutates the original | Returns a new array | | --------------------------------- | ------------------------------------ | | arr.sort(compareFn) | arr.toSorted(compareFn) | | arr.reverse() | arr.toReversed() | | arr.splice(start, count, ...x) | arr.toSpliced(start, count, ...x) | | arr[i] = value | arr.with(i, value) |

All four ES2023 non-mutating forms ship in every runtime the course targets: Node 20+ (Node 24 LTS is the course’s pinned runtime) and every current evergreen browser. Reach for them whenever the array is held outside the function’s own scope.

A one-line swap is the entire fix for the chapter opener’s bug:

setInvoices(invoices.sort((a, b) => a.amountCents - b.amountCents));

Same reference, so React skips the re-render. .sort() reorders invoices in place and returns the same array. React’s reconciler compares the new state to the old by reference, sees that they are identical, and skips the re-render. The list stays as it was, even though the underlying array is now sorted.

.toSpliced is the one whose mutating sibling people most often already know. Its signature is the same as .splice: (start, deleteCount, ...itemsToInsert). The first argument is where to start, the second is how many to delete, and the rest are items to insert at that position. Call it with (2, 1) to remove one element at index 2, or (2, 0, newItem) to insert without removing anything. The non-mutating version returns the new array. The mutating original instead returns the removed items as an array, which is confusing design quite apart from the mutation. Reach for .toSpliced once the array is shared rather than owned by the function.

Four mutating methods earn their place when the array is yours:

  • .push(item) appends and returns the new length.
  • .pop() removes the last element and returns it, or undefined if the array is empty.
  • .shift() removes the first element and returns it, or undefined if the array is empty.
  • .unshift(item) prepends and returns the new length.

Add .sort, .reverse, .splice, and bracket assignment from the table above. All of them are correct when the array is declared inside the function building it. The pattern to recognize is this: you declared const result = [] a few lines ago, you’re pushing into it inside a for...of, and nobody outside this function will ever see the intermediate state.

const formatPaidLines = (
invoices: { id: string; amountCents: number; status: string }[],
): string[] => {
const lines: string[] = [];
for (const invoice of invoices) {
if (invoice.status === 'paid') {
lines.push(`${invoice.id}: $${(invoice.amountCents / 100).toFixed(2)}`);
}
}
return lines;
};

lines is built up with .push inside the loop, and that’s correct: the array never escapes mid-construction, so the caller only ever sees the finished result. The same shape rewritten as .filter().map() would be cleaner here, and the next lesson will cover that, but the imperative form is still defensible rather than a code smell.

The wrong move is the one that looks superficially similar:

One legacy form is worth recognizing but not writing: arr.length = 0 clears an array in place. It compiles, it works, and it’s how clears were done before reassignment became the cleaner answer. When you want a binding to point to an empty array, use arr = [] for a let binding, or setArr([]) in React state. Recognize the length-write when you see it in older code, but reach for reassignment in new code.

Spread and .slice(): when you do want a copy

Section titled “Spread and .slice(): when you do want a copy”

Two forms produce a shallow copy of an array. They’re not part of the ES2023 family, but they serve the same job in the same way.

[...arr, newItem] is the default for adding one or two items at the boundary. [...arr, x] appends, [x, ...arr] prepends, and [head, ...rest] decomposes. This is the form React state setters reach for when adding a row to a list.

const invoices = [
{ id: 'inv_001', amountCents: 4900, status: 'paid' },
{ id: 'inv_002', amountCents: 1200, status: 'pending' },
];
const withNew = [...invoices, { id: 'inv_003', amountCents: 9900, status: 'pending' }];
const lastThree = invoices.slice(-3);

arr.slice() is the default for “shallow copy of the whole thing” or “extract a sub-range without mutating.” arr.slice(0, 3) gives the first three, arr.slice(-3) gives the last three, and arr.slice() with no arguments clones the whole array. It’s the non-mutating cousin of .splice. The two names sit one letter apart, which makes them easy to confuse, so it helps to fix the difference in your memory once: .slice reads, .splice mutates.

Both copy shallowly. Nested objects inside the array keep their reference, the same rule as object spread, covered in the first lesson on the value model. You get a new outer array, not a deep clone of the contents.

To choose between spread and the ES2023 methods, think about where the change lands: spread is for the boundary, while .with and .toSpliced are for the middle. For appending or prepending, [...arr, x] is the cleanest. For replacing one item by index, arr.with(i, updated) beats [...arr.slice(0, i), updated, ...arr.slice(i + 1)], since that’s one method call instead of three concatenated slices. For removing one item by index, arr.toSpliced(i, 1) beats the same hand-rolled slice work.

Array.from(iterable, mapFn?) converts any iterable into an array. Iterables include Set, NodeList, generators, strings, arguments, and URLSearchParams: anything that implements the iteration protocol, which gets its own treatment later in this chapter. The optional second argument folds a .map step into the conversion in a single pass.

The canonical idiom is deduplicating an array of primitives:

const tags = ['paid', 'pending', 'paid', 'overdue', 'pending'];
const unique = Array.from(new Set(tags));
// unique is ['paid', 'pending', 'overdue']

new Set(arr) collapses duplicates by SameValueZero equality, and Array.from turns the resulting Set back into an array. It’s one line and reads as intent. When Set earns its place beyond this dedup idiom is a later lesson in this chapter.

Array.of(...items) exists for one narrow reason: Array(3) is ambiguous. With a single numeric argument, Array(3) builds a length-3 sparse array, meaning three holes rather than three undefineds, and most array methods skip those holes silently. Array.of(3) builds [3], treating the argument as an element. The course writes array literals ([3]) by default and reaches for Array.of essentially never, so recognize what it solves and move on.

This lesson covered the whole-array operations: reading, copying, and reshaping. The element-by-element methods (.map, .filter, .reduce, .find, and friends) are the next lesson’s job. A few of these ideas return later:

  • React useState<T[]> and the non-mutating update rule. Every setState call replaces the array reference, so spread, .toSorted, .toSpliced, and .with are the daily reach when the array lives in React state.
  • Drizzle’s select queries hand back T[]. Transforms applied before the response use the methods from this lesson and the array methods from the next.
  • Array.from(formData.entries()) in Server Actions. Running Array.from over the form’s pair iterator is how form input becomes data the rest of the action can validate and operate on.

Here are two short programs, back to back. The contrast between them is the whole point, so read the first, commit to a prediction, then move to the second.

Predict what this program prints, then press Check.

const original = [3, 1, 2];
const sorted = original.sort((a, b) => a - b);
console.log(sorted, original, sorted === original);

Predict what this program prints, then press Check.

const original = [3, 1, 2];
const sorted = original.toSorted((a, b) => a - b);
console.log(sorted, original, sorted === original);

The component below has the chapter opener’s bug. The “Sort by amount” button calls .sort() directly on the state array, then hands the same reference back to setInvoices, so the list never re-orders on screen even though the underlying array really is sorted.

Swap the mutating method for the non-mutating twin so React sees a new array reference and re-renders the list.

The list never re-orders when you click Sort by amount. Find the line that's mutating in place and reach for the non-mutating twin so React sees a new array.

Preview