A sort is stable when items that compare as equal keep the order they had before the sort. With a compare function, equal means it returns 0 for the pair: sorted by age, two people of the same age are equal, even though their names differ. A stable sort leaves them in the order they came in. An unstable sort may put them either way round, and which way can depend on the engine or on how long the array is.
Picture a queue at a ticket office that lets members go first. A fair clerk moves the members to the front, but keeps the members in the order they arrived, and everyone else too. That's a stable sort. An unfair one also gets the members to the front, but mixes up who was standing where.
const people = [
{ name: "Ada", age: 30 },
{ name: "Bo", age: 25 },
{ name: "Cy", age: 30 },
{ name: "Di", age: 25 },
];
people.toSorted((a, b) => a.age - b.age).map((person) => person.name);
// ["Bo", "Di", "Ada", "Cy"]: Bo stays ahead of Di, and Ada ahead of Cy
| Position | Before | After a stable sort by age | After an unstable sort, one possible result |
|---|---|---|---|
| 1 | Ada, 30 | Bo, 25 | Di, 25 |
| 2 | Bo, 25 | Di, 25 | Bo, 25 |
| 3 | Cy, 30 | Ada, 30 | Cy, 30 |
| 4 | Di, 25 | Cy, 30 | Ada, 30 |
Both results are sorted by age, and the compare function can't tell them apart. Only the stable one is predictable: you know where Bo and Di will be before you sort.
In a UI, stability is what lets one sort build on another. The list above started in name order, and after the stable sort by age, the people of each age are still in name order. A sortable table works the same way: sort it by name, then click the Age header, and people of the same age stay in name order. With an unstable sort, each click could scramble the rows that tie, even when nothing in the data changed.
Since ES2019, the specification requires Array.prototype.sort to be stable, so every current engine sorts stably, and toSorted, added in ES2023, is stable too. Before that, engines could choose: until Chrome 70, in 2018, Chrome sorted arrays of more than 10 elements with an unstable sort. A polyfill, or any sort you write yourself, has to be stable on purpose.
Sorting algorithms differ here. Merge sort and insertion sort are stable. Quicksort, selection sort, and heap sort, in their usual forms, are not: they can move an item past one it's equal to. V8, the engine in Chrome and Node, sorts with TimSort, a stable mix of merge sort and insertion sort.
Write sortBy(array, criteria). It returns a new array with the items of array, sorted. array itself never changes. criteria is an array of sort keys, the most important first.
Each criterion is a property name, a function that takes an item and returns its sort value, or an object { key, order }. In the object form, key is a property name or a function, and order is "asc" (the default) or "desc".
Items are compared by the first criterion. When they tie, the next criterion decides, and so on. Items that tie on every criterion keep their original order, in both directions: the sort is stable. With no criteria, the result is a copy in the original order.
Sort values are numbers, strings, or dates; compare them with < and >. Two values tie when neither is smaller than the other, so two dates with the same time tie.
null and undefined values always go last, whatever the order, and they tie with each other.
A key function may be slow, so call each one at most once per item.
const people = [
{ name: "Cy", age: 30 },
{ name: "Ada", age: 36 },
{ name: "Bo", age: 30 },
{ name: "Di", age: null },
];
sortBy(people, ["age"]).map((p) => p.name);
// ["Cy", "Bo", "Ada", "Di"]: Cy and Bo tie, so they keep their order
sortBy(people, [{ key: "age", order: "desc" }, "name"]).map((p) => p.name);
// ["Ada", "Bo", "Cy", "Di"]: Di's null age goes last, even in "desc"
sortBy(people, [(p) => p.name.length]).map((p) => p.name);
// ["Cy", "Bo", "Di", "Ada"]
expect(sortBy([{ v: 10 }, { v: 9 }, { v: 100 }], ["v"]).map((x) => x.v)).toEqual([9, 10, 100]);
expect(sortBy([{ name: "Cy" }, { name: "Ada" }, { name: "Bo" }], ["name"]).map((x) => x.name)).toEqual(["Ada", "Bo", "Cy"]);
expect(sortBy(["ccc", "a", "bb"], [(s) => s.length])).toEqual(["a", "bb", "ccc"]);
expect(sortBy([{ a: 1, b: 5 }, { a: 2, b: 1 }], [(x) => x.a + x.b]).map((x) => x.a)).toEqual([2, 1]);
const people = [
{ name: "Cy", age: 30 },
{ name: "Ada", age: 36 },
{ name: "Bo", age: 30 },
{ name: "Al", age: 30 },
];
expect(sortBy(people, ["age", "name"]).map((p) => p.name)).toEqual(["Al", "Bo", "Cy", "Ada"]);
const people = [
{ name: "Cy", age: 30 },
{ name: "Ada", age: 36 },
{ name: "Bo", age: 30 },
{ name: "Al", age: 25 },
];
expect(sortBy(people, [{ key: "age", order: "desc" }, "name"]).map((p) => p.name)).toEqual(["Ada", "Bo", "Cy", "Al"]);
expect(sortBy(["bb", "a", "ccc"], [{ key: (s) => s.length, order: "desc" }])).toEqual(["ccc", "bb", "a"]);
expect(sortBy([{ v: 2 }, { v: 1 }], [{ key: "v", order: "asc" }]).map((x) => x.v)).toEqual([1, 2]);
const items = [
{ group: 2, id: "a" },
{ group: 1, id: "b" },
{ group: 2, id: "c" },
{ group: 1, id: "d" },
{ group: 2, id: "e" },
];
expect(sortBy(items, ["group"]).map((x) => x.id)).toEqual(["b", "d", "a", "c", "e"]);
expect(sortBy(items, [{ key: "group", order: "desc" }]).map((x) => x.id)).toEqual(["a", "c", "e", "b", "d"]);
const items = [{ v: 2 }, { v: null }, { v: 1 }, {}, { v: 3 }];
expect(sortBy(items, ["v"]).map((x) => x.v)).toEqual([1, 2, 3, null, undefined]);
expect(sortBy(items, [{ key: "v", order: "desc" }]).map((x) => x.v)).toEqual([3, 2, 1, null, undefined]);
const missing = [{ v: undefined, id: "b" }, { v: null, id: "a" }, { v: 0, id: "c" }];
expect(sortBy(missing, ["v", "id"]).map((x) => x.id)).toEqual(["c", "a", "b"]);
const events = [
{ at: new Date(2024, 0, 2), id: "x" },
{ at: new Date(2024, 0, 1), id: "y" },
{ at: new Date(2024, 0, 2), id: "a" },
{ at: new Date(2024, 0, 2), id: "m" },
];
expect(sortBy(events, ["at", "id"]).map((e) => e.id)).toEqual(["y", "a", "m", "x"]);
expect(sortBy(events, [{ key: "at", order: "desc" }, "id"]).map((e) => e.id)).toEqual(["a", "m", "x", "y"]);
const items = Array.from({ length: 40 }, (_, i) => ({ group: (i * 7) % 5, id: i }));
const byGroup = spy((item) => item.group);
const byId = spy((item) => item.id);
const sorted = sortBy(items, [byGroup, { key: byId, order: "desc" }]);
expect(byGroup.callCount).toBeLessThanOrEqual(40);
expect(byId.callCount).toBeLessThanOrEqual(40);
expect(sorted.slice(0, 3).map((x) => x.id)).toEqual([35, 30, 25]);
expect(sorted.map((x) => x.group)).toEqual([...items.map((x) => x.group)].sort((x, y) => x - y));
const items = [{ n: 3 }, { n: 1 }, { n: 2 }];
const sorted = sortBy(items, ["n"]);
expect(sorted).not.toBe(items);
expect(items.map((x) => x.n)).toEqual([3, 1, 2]);
const copy = sortBy(items, []);
expect(copy).not.toBe(items);
expect(copy.map((x) => x.n)).toEqual([3, 1, 2]);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with sortBy with several keys. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.