Lesson 0.5 · JavaScript in general
Arrays II: reduce, sorting and grouping
reduce in depth, toSorted, Object.groupBy, Set, flatMap and the methods that change an array.
Loading lesson…
Lesson 0.5 · JavaScript in general
reduce in depth, toSorted, Object.groupBy, Set, flatMap and the methods that change an array.
Loading lesson…
In the last lesson an array always turned into another array (map, filter), one item (find) or yes/no (some). But often you need to make one summary value from the whole array: the total price of the cart, the most expensive item, counts per category.
Picture a cashier ringing up your shopping. They start with zero on the display and add the price of every item on the belt. At the end they show one number. That's exactly what reduce does – and because the "running total" can be anything (a number, an object, an array), it can do surprisingly much.
The second half of the lesson is about sorting, grouping and the methods that quietly change the original array – and their safe replacements.
const prices = [12, 15, 6]const total = prices.reduce((sum, price) => sum + price, 0)// ↑ ↑ ↑// accumulator item initial value// total = 33
reduce gets two things: a function and the initial value of the accumulator. The function is called for every item. It gets "the result so far" (the accumulator) and the current item and returns the new result so far. That's what it gets on the next call.
| Step | sum (accumulator) | price | the function returns |
|---|---|---|---|
| 1. | 0 (initial value) | 12 | 0 + 12 = 12 |
| 2. | 12 | 15 | 12 + 15 = 27 |
| 3. | 27 | 6 | 27 + 6 = 33 |
| end | 33 – the result of reduce |
const cart = [{ name: 'Apple', price: 12, qty: 3 },{ name: 'Bread', price: 45, qty: 1 },{ name: 'Milk', price: 22, qty: 2 },]const total = cart.reduce((sum, item) => sum + item.price * item.qty, 0) // 125const count = cart.reduce((sum, item) => sum + item.qty, 0) // 6const average = total / count // 20.83…// Maximum: the accumulator remembers the biggest so farconst maxPrice = cart.reduce((max, item) => (item.price > max ? item.price : max), 0) // 45// The most expensive ITEM (an object): the accumulator is the most expensive item so farconst priciest = cart.reduce((best, item) => (item.price > best.price ? item : best)) // Bread// (without an initial value it starts with the first element – fine here, as long as the array isn't empty)
const votes = ['yes', 'no', 'yes', 'abstain', 'yes']const counts = votes.reduce((acc, vote) => {acc[vote] = (acc[vote] ?? 0) + 1return acc // don't forget to return the accumulator!}, {})// { yes: 3, no: 1, abstain: 1 }// The same without changing the accumulator (spread) – longer, but "pure"const counts = votes.reduce((acc, vote) => ({ ...acc, [vote]: (acc[vote] ?? 0) + 1 }), {})
const users = [{ id: 7, name: 'Anna' }, { id: 9, name: 'Peter' }]const byId = users.reduce((acc, user) => {acc[user.id] = userreturn acc}, {})// { 7: { id: 7, name: 'Anna' }, 9: { id: 9, name: 'Peter' } }byId[9].name // 'Peter' – a fast lookup without going through the array// A shorter alternative without reduce:Object.fromEntries(users.map((u) => [u.id, u]))
What you see: Four items and four different reducers. The table shows what was in the accumulator before every step and what the function returned.
Try it:
Object.groupBy can do this too.The takeaway: reduce is a loop with a memory. The function always gets what it returned last time and returns a new version. The initial value decides the type of the accumulator (a number, an object, an array).
Loading the interactive part…
Modern JavaScript can split an array into groups by some property with a single call:
const products = [{ name: 'Apple', category: 'fruit' },{ name: 'Carrot', category: 'vegetables' },{ name: 'Pear', category: 'fruit' },]const groups = Object.groupBy(products, (p) => p.category)// {// fruit: [{ name: 'Apple', … }, { name: 'Pear', … }],// vegetables: [{ name: 'Carrot', … }],// }// The group can be decided by a calculation tooObject.groupBy(products, (p) => (p.name.length > 5 ? 'long' : 'short'))// Typical use: go through the groupsfor (const [category, items] of Object.entries(groups)) {console.log(category, items.map((p) => p.name).join(', '))}// fruit Apple, Pear// vegetables Carrot
You sort with the toSorted method (a new array) or the older sort (changes the original array). Both get a compare function: it gets two items a and b and returns a number.
| The function returns | Meaning |
|---|---|
| a negative number | a goes BEFORE b |
| a positive number | a goes AFTER b |
| 0 | it doesn't matter, keep the order |
const prices = [40, 5, 100, 25]prices.toSorted((a, b) => a - b) // [5, 25, 40, 100] – ascendingprices.toSorted((a, b) => b - a) // [100, 40, 25, 5] – descendingprices // [40, 5, 100, 25] – toSorted doesn't change the original// Why a - b? When a < b, the result is negative → a goes first. Exactly what we want.
const names = ['Zoe', 'adam', 'Émile', 'Bella']names.toSorted() // ['Bella', 'Zoe', 'adam', 'Émile'] 😕 by character codesnames.toSorted((a, b) => a.localeCompare(b, 'en')) // ['adam', 'Bella', 'Émile', 'Zoe'] ✅ alphabetical orderconst users = [{ name: 'Peter', age: 25 },{ name: 'Anna', age: 31 },{ name: 'Eve', age: 25 },]users.toSorted((a, b) => a.age - b.age) // by ageusers.toSorted((a, b) => a.name.localeCompare(b.name, 'en')) // by name// By two criteria: age first, the name when they matchusers.toSorted((a, b) => a.age - b.age || a.name.localeCompare(b.name, 'en'))// Eve (25), Peter (25), Anna (31)// when a.age - b.age is 0 (falsy), the part after || is used// By dateorders.toSorted((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) // newest first// Booleans: unfinished firsttasks.toSorted((a, b) => Number(a.done) - Number(b.done))
const letters = ['a', 'b', 'c', 'd', 'e']letters.slice(1, 3) // ['b', 'c'] – from index 1 to 3 (3 not included), a new arrayletters.slice(2) // ['c', 'd', 'e'] – from 2 to the endletters.slice(-2) // ['d', 'e'] – the last twoletters.slice(0, 3) // ['a', 'b', 'c'] – the first three ("top 3")letters.slice() // a copy of the whole arrayconst a = [1, 2]const b = [3, 4][...a, ...b] // [1, 2, 3, 4] – joining[0, ...a, 2.5, ...b] // [0, 1, 2, 2.5, 3, 4] – with values inserteda.concat(b) // [1, 2, 3, 4] – the same, the old way['Anna', 'Peter'].join(', ') // 'Anna, Peter'['a', 'b'].join('') // 'ab'
[[1, 2], [3], [4, 5]].flat() // [1, 2, 3, 4, 5] – flattens by one levelconst posts = [{ title: 'A', tags: ['react', 'js'] },{ title: 'B', tags: ['css'] },]posts.map((p) => p.tags) // [['react', 'js'], ['css']] – an array of arraysposts.flatMap((p) => p.tags) // ['react', 'js', 'css'] – map + flat in one
Set is a collection that remembers every value only once. Ideal for removing duplicates:
const tags = ['react', 'js', 'react', 'css', 'js']const unique = new Set(tags) // Set { 'react', 'js', 'css' }unique.size // 3unique.has('css') // true[...unique] // ['react', 'js', 'css'] – back to an array// The one-line classicconst uniqueTags = [...new Set(tags)]// Unique categories from an array of objectsconst categories = [...new Set(products.map((p) => p.category))]
A few older methods don't write a new array but overwrite the original one. In a plain script it doesn't matter, but when the array belongs to someone else (React state, say), it's a source of nasty bugs. Each one has a safe replacement:
| ❌ Changes the original array | ✅ Returns a new array | What it does |
|---|---|---|
arr.push(x) | [...arr, x] | adds to the end |
arr.unshift(x) | [x, ...arr] | adds to the start |
arr.pop() | arr.slice(0, -1) | removes the last one |
arr.shift() | arr.slice(1) | removes the first one |
arr.splice(i, 1) | arr.toSpliced(i, 1) | removes at position i |
arr.splice(i, 0, x) | arr.toSpliced(i, 0, x) | inserts at position i |
arr[i] = x | arr.with(i, x) | replaces at position i |
arr.sort(fn) | arr.toSorted(fn) | sorts |
arr.reverse() | arr.toReversed() | reverses the order |
const original = [3, 1, 2]const sorted = original.sort((a, b) => a - b)sorted // [1, 2, 3]original // [1, 2, 3] 😱 the original changed too – sorted and original are THE SAME arrayconst original2 = [3, 1, 2]const sorted2 = original2.toSorted((a, b) => a - b)sorted2 // [1, 2, 3]original2 // [3, 1, 2] ✅
Add up the numbers in an array and sort them from the smallest. The arrays are frozen, so sort won't work.
CHALLENGE: Sum and sort 1. total([4, 1, 5]) should return 10. Use reduce with the initial value 0. 2. sortAsc([4, 1, 5]) should return [1, 4, 5]. Use toSorted((a, b) => a - b).
Loading the interactive part…
The first challenge combines everything from the array lessons on real product data. The second one practises reduce, Set, sorting and flatMap – and the cart is frozen, so sort won't work.
The statistics table is finished, it just contains made-up numbers. Replace them with calculations from the products array – each value as a single expression.
CHALLENGE: Shop statistics (array methods)
Data: the `products` array from src/course/fakeApi.en.ts.
Every product looks like this: { id: 1, name: 'Apple', category: 'fruit', price: 12, inStock: true }
The table is finished, only all the values are "hard-coded" for now. Replace each of them
with a calculation from the `products` array – always as a single expression (method chaining is fine).
1. `inStockCount` – how many products are in stock (filter + length).
2. `stockValue` – the sum of the prices of products in stock (filter + reduce, initial value 0).
3. `hasDairy` – is there at least one product from the 'dairy' category? (some)
4. `allCheap` – do all products cost less than $50? (every)
5. `product8` – the name of the product with id 8 (find; find can return undefined → use ?.name)
6. `cheapest` – the name of the cheapest product (toSorted by price and take the first item).
7. `fruitNames` – the fruit names sorted alphabetically and joined with a comma:
filter → map → toSorted((a, b) => a.localeCompare(b, 'en')) → join(', ')
You must not change the `products` array while doing it (no sort, push, splice…).Loading the interactive part…
Seven functions that calculate summaries from a cart: the total price, counts, prices per category, unique values and a ranking of the most expensive items.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
Object.groupBy splits an array into groups, Math.max(...arr) finds the maximum of numbers.(a, b) => a - b, text a.localeCompare(b, 'en'), several criteria with ||. Without a function it sorts as text.slice cuts out, spread joins, flatMap flattens, [...new Set(arr)] removes duplicates.toSpliced, with, toSorted, toReversed.Checks, deepFreeze, equal, Check)Checks, deepFreeze, equal, Check – from the file src/course/checks.tsx. The course helper for function-style challenges: runs the tests and shows ✅/❌. You don't need to know how it works inside – it just tells you which steps are done./** One test: a description and a function returning true (passed) or false. It may be async. */export type Check = [name: string, test: () => boolean | Promise<boolean>]/*** Runs the tests of a challenge and shows ✅ or ❌ for each.* An error thrown in a test (e.g. when trying to change a frozen object) is shown under the test.*/export function Checks({ tests }: { tests: Check[] }) {const t = useT()const [results, setResults] = useState<Result[]>(() => tests.map(() => ({ status: 'pending' })))useEffect(() => {let ignore = falsetests.forEach(([, test], i) => {Promise.resolve().then(test).then((ok): Result => (ok ? { status: 'ok' } : { status: 'fail' }),(e): Result => ({ status: 'fail', error: e instanceof Error ? e.message : String(e) }),).then((result) => {if (ignore) returnsetResults((prev) => prev.map((r, idx) => (idx === i ? result : r)))})})return () => {ignore = true}}, [tests])const passed = results.filter((r) => r.status === 'ok').lengthreturn (<div className="stack"><strong className={passed === tests.length ? 'ok' : undefined}>{t.checksPassed(passed, tests.length)}</strong><ul className="list">{tests.map(([name], i) => {const r = results[i] ?? { status: 'pending' }return (<li key={name}>{r.status === 'pending' ? '⏳' : r.status === 'ok' ? '✅' : '❌'} {name}{r.status === 'fail' && r.error && (<div className="bad muted">{t.checksError} {r.error}</div>)}</li>)})}</ul></div>)}/** Freezes an object and all nested objects and arrays – any attempt to change them throws. */export function deepFreeze<T>(value: T): T {if (value && typeof value === 'object') {Object.values(value).forEach(deepFreeze)Object.freeze(value)}return value}/** Compares two values by content (arrays and objects item by item), not by reference. */export function equal(a: unknown, b: unknown): boolean {return JSON.stringify(a) === JSON.stringify(b)}
products)products – from the file src/course/fakeApi.en.ts. The course fake API: data and functions pretending to be a server (with a delay, sometimes even with an error). In a real app you would call fetch() here.export interface Product {id: numbername: stringcategory: 'fruit' | 'vegetables' | 'bakery' | 'dairy'price: numberinStock: boolean}export const products: Product[] = [{ id: 1, name: 'Apple', category: 'fruit', price: 12, inStock: true },{ id: 2, name: 'Banana', category: 'fruit', price: 8, inStock: true },{ id: 3, name: 'Pear', category: 'fruit', price: 15, inStock: false },{ id: 4, name: 'Carrot', category: 'vegetables', price: 6, inStock: true },{ id: 5, name: 'Tomato', category: 'vegetables', price: 9, inStock: true },{ id: 6, name: 'Cucumber', category: 'vegetables', price: 19, inStock: false },{ id: 7, name: 'Bread roll', category: 'bakery', price: 3, inStock: true },{ id: 8, name: 'Bread', category: 'bakery', price: 45, inStock: true },{ id: 9, name: 'Milk', category: 'dairy', price: 22, inStock: true },{ id: 10, name: 'Cheddar', category: 'dairy', price: 39, inStock: true },{ id: 11, name: 'Yogurt', category: 'dairy', price: 14, inStock: false },{ id: 12, name: 'Orange', category: 'fruit', price: 11, inStock: true },]
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).