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.
Indexing under strict TypeScript
Section titled “Indexing under strict TypeScript”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 guardThe 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() reads from either end
Section titled “.at() reads from either end”.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.
setInvoices(invoices.toSorted((a, b) => a.amountCents - b.amountCents));New array, new reference, so the render lands. .toSorted builds a fresh array with the same elements in sorted order, leaving invoices alone. setInvoices receives the new reference, React sees a different array, re-runs the render, and the list reorders on screen.
.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.
When mutate-in-place is still right
Section titled “When mutate-in-place is still right”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, orundefinedif the array is empty..shift()removes the first element and returns it, orundefinedif 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 and Array.of
Section titled “Array.from and Array.of”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.
Where this lands later
Section titled “Where this lands later”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. EverysetStatecall replaces the array reference, so spread,.toSorted,.toSpliced, and.withare the daily reach when the array lives in React state. - Drizzle’s
selectqueries hand backT[]. 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. RunningArray.fromover the form’s pair iterator is how form input becomes data the rest of the action can validate and operate on.
Predict the output
Section titled “Predict the output”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);.sort() mutates original in place and returns the same reference, so both bindings point at the same — now sorted — array. The strict-equality check is true because there’s only ever been one array. Reach for .toSorted when you want original to stay as it was.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);.toSorted builds a new array with the same elements in sorted order and leaves original alone. Different references (false on ===), different contents — one sorted, one as-declared.Fix the silent re-render
Section titled “Fix the silent re-render”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.
External resources
Section titled “External resources”The canonical React guide to the mutate-vs-replace decision, with a method table that maps cleanly onto this lesson's reflex.
Reference for the headline ES2023 non-mutating method, with links to its three siblings.
The compiler-flag reference for the strict-indexing rule the course pins, with the type-narrowing examples it implies.
The Stage 4 spec proposal that shipped toSorted, toReversed, toSpliced, and with, the motivation behind the four-pair pattern.