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.
The page has a table. Each column that can be sorted has a <button> in its header, and a header with data-type="number" holds numbers:
<table id="team">
<caption>Team</caption>
<thead>
<tr>
<th><button type="button">Name</button></th>
<th data-type="number"><button type="button">Commits</button></th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr><td>Zoe</td><td>9</td><td>zoe@example.com</td></tr>
<tr><td>adam</td><td>41</td><td>adam@example.com</td></tr>
<tr><td>Émile</td><td>100</td><td>emile@example.com</td></tr>
<tr><td>Bea</td><td>41</td><td>bea@example.com</td></tr>
</tbody>
</table>
Write makeSortable(table). Clicking a header's button sorts the rows of the table's <tbody> by the text of their cells in that column. The first click on a column sorts it ascending, and each click on the sorted column flips it between ascending and descending.
Number columns compare as numbers, so 9 comes before 41. Other columns compare their text the way a.localeCompare(b) does, so adam and Émile sort among the capitalized names. Rows that tie keep the order they were in.
Set aria-sort on the sorted column's <th> to "ascending" or "descending", and remove aria-sort from every other header, so screen readers hear about the sort in effect and no other.
Move the existing <tr> elements rather than building new ones, so anything attached to them stays. If a header already has aria-sort when you set up, its column counts as sorted that way: the first click on it flips the direction.
Clicks anywhere else, such as on a header without a button, change nothing.
makeSortable(document.getElementById("team"));
// click "Commits": the rows go 9, 41 (adam), 41 (Bea), 100, and that header has aria-sort="ascending"
// click "Commits" again: 100, 41 (adam), 41 (Bea), 9, with aria-sort="descending"
// click "Name": adam, Bea, Émile, Zoe; only the Name header has aria-sort
const table = document.getElementById("team");
const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
const name = table.tHead.rows[0].cells[0];
makeSortable(table);
fire(name.querySelector("button"), "click");
expect(column(0)).toEqual(["adam", "Bea", "Émile", "Zoe"]);
expect(column(2)).toEqual(["adam@example.com", "bea@example.com", "emile@example.com", "zoe@example.com"]);
expect(name.getAttribute("aria-sort")).toBe("ascending");
const table = document.getElementById("team");
const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
const name = table.tHead.rows[0].cells[0];
makeSortable(table);
fire(name.querySelector("button"), "click");
fire(name.querySelector("button"), "click");
expect(column(0)).toEqual(["Zoe", "Émile", "Bea", "adam"]);
expect(name.getAttribute("aria-sort")).toBe("descending");
fire(name.querySelector("button"), "click");
expect(column(0)).toEqual(["adam", "Bea", "Émile", "Zoe"]);
expect(name.getAttribute("aria-sort")).toBe("ascending");
const table = document.getElementById("team");
const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
makeSortable(table);
fire(table.tHead.rows[0].cells[1].querySelector("button"), "click");
expect(column(1)).toEqual(["9", "41", "41", "100"]);
fire(table.tHead.rows[0].cells[1].querySelector("button"), "click");
expect(column(1)).toEqual(["100", "41", "41", "9"]);
const table = document.getElementById("team");
const headers = [...table.tHead.rows[0].cells];
makeSortable(table);
fire(headers[0].querySelector("button"), "click");
fire(headers[1].querySelector("button"), "click");
expect(headers.map((th) => th.getAttribute("aria-sort"))).toEqual([null, "ascending", null]);
const table = document.getElementById("team");
const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
const [name, commits] = table.tHead.rows[0].cells;
makeSortable(table);
fire(name.querySelector("button"), "click");
fire(name.querySelector("button"), "click");
fire(commits.querySelector("button"), "click");
expect(column(0)).toEqual(["Zoe", "Bea", "adam", "Émile"]);
fire(commits.querySelector("button"), "click");
expect(column(0)).toEqual(["Émile", "Bea", "adam", "Zoe"]);
const table = document.getElementById("team");
const before = [...table.tBodies[0].rows];
makeSortable(table);
fire(table.tHead.rows[0].cells[1].querySelector("button"), "click");
const after = [...table.tBodies[0].rows];
expect(after).toHaveLength(4);
expect(after.filter((row) => !before.includes(row))).toHaveLength(0);
expect(after[0]).toBe(before[0]);
expect(after[3]).toBe(before[2]);
const table = document.getElementById("team");
const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
const name = table.tHead.rows[0].cells[0];
name.setAttribute("aria-sort", "ascending");
makeSortable(table);
fire(name.querySelector("button"), "click");
expect(column(0)).toEqual(["Zoe", "Émile", "Bea", "adam"]);
expect(name.getAttribute("aria-sort")).toBe("descending");
const table = document.getElementById("team");
const column = (index) => [...table.tBodies[0].rows].map((row) => row.cells[index].textContent);
const headers = [...table.tHead.rows[0].cells];
makeSortable(table);
fire(headers[2], "click");
fire(table.tBodies[0].rows[0].cells[0], "click");
fire(table.caption, "click");
expect(column(0)).toEqual(["Zoe", "adam", "Émile", "Bea"]);
expect(headers.map((th) => th.getAttribute("aria-sort"))).toEqual([null, null, null]);
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 Sortable table. 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.