Arrays and the non-mutating update
The JavaScript array surface for reading, copying, and reshaping: strict-TypeScript indexing and the ES2023 non-mutating update methods that keep reference-comparing systems re-rendering.
Picture a React component that lists invoices behind a “sort by amount” button. The click handler calls .sort on the state array, and clicking 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 array really is reordered, yet the screen never updates. .sort() reorders in place, so invoices keeps the same reference; React compares the new value against the old, sees no change, and skips the render. The fix isn’t a defensive copy like [...invoices].sort(...), but the ES2023 non-mutating twin, invoices.toSorted(...), which returns a new array with a new reference.
You’ll meet React in a later unit, so take this snippet on faith. The point isn’t React but the class of bug: any system that re-runs only when a reference changes will miss an in-place mutation, and most tools you’ll build on work that way. This lesson covers the array operations that stay safe there: index reads under strict TypeScript, .at() for positional access, the four non-mutating update methods, and when mutating in place is still the right call.
Indexing under strict TypeScript
Section titled “Indexing under strict TypeScript”The course pins noUncheckedIndexedAccess , so reading an array by index gives you T | undefined, not T, the same rule the previous lesson applied to object property reads.
const amounts: number[] = [4900, 1200];const first = amounts[0];const firstOrZero = amounts[0] ?? 0;Use first as a number without handling the undefined and you’ll get the error 'amounts[0]' is possibly 'undefined'. Two approaches close the gap.
Use ?? fallback when a sensible default exists. ?? falls back only on null or undefined, never on 0 or '', so a legitimate 0 in the array survives, and the result narrows to the element type with no undefined to thread through callers.
Use a temporary binding 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 guardA length check does not help, though it’s the first thing most people reach for:
if (amounts.length > 0) { const total = amounts[0] * 2; // TS error: amounts[0] is possibly 'undefined'}It doesn’t prove index 0 is populated, because sparse arrays exist and the type system doesn’t model them.
.at() and negative indices
Section titled “.at() and negative indices”.at() does positional access and counts from the end for negative indices, returning T | undefined.
const amounts = [4900, 1200, 9900, 3300];
const first = amounts.at(0);const last = amounts.at(-1);const secondToLast = amounts.at(-2);For positive indices it matches bracket access, so use whichever reads cleaner. For the last element, prefer .at(-1) over arr[arr.length - 1]: it reads as the intent (“the last one”) with no index math to get wrong.
Mutate owned arrays, replace shared ones
Section titled “Mutate owned arrays, replace shared ones”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 handed to a caller that keeps a reference, mutating methods are fine: nobody outside can see it mid-construction. The next section returns to this case.
When the array is shared, held in React state, passed in as props, sitting in a Map, or returned to a caller that keeps the reference, mutating in place is a bug. The reference never changes, so any system that re-runs by comparing references, React’s reconciler chief among them, sees no change and skips the work. The fix is the non-mutating twin: a new array with a new reference.
Four pairs cover the whole pattern.
| 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) |
The right column is the ES2023 set. 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 it, so the list stays as it was on screen.
setInvoices(invoices.toSorted((a, b) => a.amountCents - b.amountCents));New array, new reference, so the render lands. .toSorted returns a fresh sorted array and leaves invoices alone, so React re-renders and the list reorders.
.toSpliced shares its signature with .splice: (start, deleteCount, ...itemsToInsert). Call it with (2, 1) to remove one element at index 2, or (2, 0, newItem) to insert without removing. They differ in the return value: .toSpliced returns the new array, while .splice returns the removed items and mutates in place.
Mutating an array the function owns
Section titled “Mutating an array the function owns”Four mutating methods are the right fit when the array is yours:
.push(item)appends and returns the new length..pop()removes the last element and returns it, orundefinedif empty..shift()removes the first element and returns it, orundefinedif empty..unshift(item)prepends and returns the new length.
The same goes for .sort, .reverse, .splice, and bracket assignment from the table above, as long as the array is declared inside the function building it. The pattern: you declared const result = [] a few lines up, you push into it in a loop, and nothing outside the function sees it mid-build.
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 never escapes mid-construction, so the caller sees only the finished result. A .filter().map() rewrite, covered next lesson, would read more cleanly, but the imperative form is fine.
The wrong move looks superficially similar:
Older code clears an array in place with arr.length = 0. In new code, reassign instead: arr = [] for a let binding, or setArr([]) in React state.
Spread and .slice() for shallow copies
Section titled “Spread and .slice() for shallow copies”Two more forms copy an array shallowly, outside the ES2023 family.
[...arr, newItem] adds items at the boundary: [...arr, x] appends, [x, ...arr] prepends, and [head, ...rest] decomposes. React state setters reach for it to add 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() copies the whole array or extracts a sub-range without mutating: arr.slice(0, 3) takes the first three, arr.slice(-3) the last three, arr.slice() clones. It sits one letter from the mutating .splice and is easy to confuse, so fix it once: .slice reads, .splice mutates.
Both copy shallowly: a new outer array, but nested objects keep their reference, the same rule object spread followed.
Spread and the ES2023 methods split by where the change lands. Spread handles the boundary; .with and .toSpliced handle the middle, each replacing or removing one item by index in a single call instead of the hand-rolled [...arr.slice(0, i), updated, ...arr.slice(i + 1)].
Array.from and Array.of
Section titled “Array.from and Array.of”Array.from(iterable, mapFn?) converts any iterable, anything implementing the iteration protocol, into an array: a Set, a NodeList, a generator, a string. The optional second argument folds a .map into the conversion in one pass.
The common idiom deduplicates an array:
const tags = ['paid', 'pending', 'paid', 'overdue', 'pending'];const unique = Array.from(new Set(tags));// unique is ['paid', 'pending', 'overdue']new Set(tags) collapses duplicates by SameValueZero equality, and Array.from turns it back into an array.
Array.of exists only to disambiguate Array(3), which builds a length-3 sparse array (three holes most methods skip silently) rather than the single-element [3]. Write the literal [3] and you never need Array.of.
Predict the output
Section titled “Predict the output”These two programs differ by a single method call.
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() sorts original in place and returns that same array, so both names point at the one array that ever existed. === is true.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 returns a new sorted array and leaves original untouched. Two arrays, so === is false.Fix the silent re-render
Section titled “Fix the silent re-render”The Sort by amount button calls .sort() on the state array and hands the same reference back to setInvoices. The array really is sorted, but React compares references, sees no change, and skips the re-render, so the list never re-orders.
Sorting doesn't update the list on screen. Replace the in-place sort with its non-mutating twin so React sees a new array reference.
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.