Sort an array of objects by any property in JavaScript
To sort an array of objects in JavaScript, sort() changes the original in place. The default order is alphabetical even on numbers, so 100 beats 9.
To sort objects by a property, pass sort() a comparator that subtracts for numbers or compares for strings:
users.sort((a, b) => a.age - b.age); // numbers, ascending
users.sort((a, b) => a.name.localeCompare(b.name)); // strings, A to Z
Two things about that first line surprise people, and both are worth knowing before you use it: sort modifies the original array, and the default order is alphabetical even for numbers.
Every result on this page came from Node v24.14.1.
The default comparator sorts as text
With no comparator, sort() converts each element to a string and compares those:
[10, 9, 100, 1].sort()
// [1, 10, 100, 9]
That is correct alphabetical order for the strings "1", "10", "100", "9", and almost never what anyone wants for numbers. It is the reason numeric sorting always needs a comparator:
[10, 9, 100, 1].sort((a, b) => a - b)
// [1, 9, 10, 100]
sort mutates
const original = [3, 1, 2];
const returned = original.sort((a, b) => a - b);
original // [1, 2, 3] <- changed
returned === original // true
sort reorders the array you gave it and returns that same array, not a copy. So the "result" and the "original" are the same object, and anything else holding a reference to it sees the new order.
This causes real bugs when the array came from somewhere else, such as props, a cache, or a module-level constant. Sorting it for display quietly reorders it everywhere.
Two ways to avoid it:
const sorted = [...users].sort((a, b) => a.age - b.age); // copy first
const sorted = users.toSorted((a, b) => a.age - b.age); // never mutates
toSorted is the modern answer and does the copying for you:
const original = [3, 1, 2];
const copy = original.toSorted((a, b) => a - b);
// original is [3, 1, 2], copy is [1, 2, 3]
It has siblings in toReversed, toSpliced and with, all of which are the non-mutating versions of methods that used to have no such option.
The comparator contract
The function receives two elements and returns a number:
- negative if
ashould come first - positive if
bshould come first - zero if their order does not matter
a.age - b.age satisfies this arithmetically, which is why subtraction is the idiom for numbers. Swap the operands for descending:
users.sort((a, b) => b.age - a.age); // descending
Subtraction only works on numbers. For strings it produces NaN, which is neither negative nor positive, and the result is an array in an unpredictable order rather than an error.
Strings need localeCompare
["apple", "Banana", "cherry"].sort((a, b) => (a < b ? -1 : 1))
// ["Banana", "apple", "cherry"]
["apple", "Banana", "cherry"].sort((a, b) => a.localeCompare(b))
// ["apple", "Banana", "cherry"]
Comparing with < uses character codes, where every capital letter sorts before every lowercase one, so Banana comes first. localeCompare uses language rules and puts them in the order a person would expect.
The difference is larger with accents:
["zebra", "éclair", "apple"].sort((a, b) => (a < b ? -1 : 1))
// ["apple", "zebra", "éclair"]
["zebra", "éclair", "apple"].sort((a, b) => a.localeCompare(b))
// ["apple", "éclair", "zebra"]
By character code, é sorts after z. By locale, it sorts where a reader expects. Any list of names or user-entered text should use localeCompare.
It also takes options, including one for numbers inside strings:
["item10", "item9"].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
// ["item9", "item10"]
Without numeric: true you get item10 first, because 1 precedes 9 as a character. This is the fix for file names, version-like labels and anything ending in a number.
Stability, and sorting by two fields
JavaScript's sort is stable: elements that compare equal keep their original relative order. That is guaranteed by the specification, not incidental.
const people = [
{ n: "Ada", a: 36 },
{ n: "Alan", a: 36 },
{ n: "Grace", a: 45 },
];
[...people].sort((x, y) => x.a - y.a).map((p) => p.n)
// ["Ada", "Alan", "Grace"]
Ada and Alan are both 36, and Ada stays ahead because she was first in the input.
For an explicit second key, chain comparators with ||:
[...people].sort((x, y) => x.a - y.a || x.n.localeCompare(y.n))
The || works because the first comparator returns 0 for a tie, which is falsy, so evaluation falls through to the second. This extends to as many keys as you need, and it is the standard idiom.
Mixing directions is easy here, unlike in some languages: negate one of them.
(x, y) => y.a - x.a || x.n.localeCompare(y.n) // age desc, name asc
undefined always sorts last
[3, undefined, 1].sort((a, b) => a - b)
// [1, 3, undefined]
undefined values are moved to the end without ever being passed to your comparator. That is specified behaviour, and it means a missing field cannot break your sort, but also that you cannot control where those items go.
null is different: it is passed to the comparator, and null - 5 is -5, so nulls behave like zero in a numeric sort and end up in the middle. If your data has both, normalise before sorting rather than after.
Sorting by a computed value
When the key is expensive to compute, computing it inside the comparator means recomputing it on every comparison. For a large array, compute once and sort the pairs:
const sorted = users
.map((u) => ({ u, key: expensive(u) }))
.sort((a, b) => a.key - b.key)
.map(({ u }) => u);
This is worth doing only when the key really is expensive, such as parsing a date string. For a plain property read, the comparator version is clearer and fast enough.
Dates deserve a note, since they are the most common computed key:
posts.sort((a, b) => new Date(b.date) - new Date(a.date)); // newest first
Subtracting two Date objects gives a number of milliseconds, so the numeric idiom works directly.
Sorting a table by whichever column was clicked
The everyday application, and it works because the comparator can be built from data rather than written out per column:
function sortBy(rows, field, direction = "asc") {
const dir = direction === "asc" ? 1 : -1;
return rows.toSorted((a, b) => {
const x = a[field];
const y = b[field];
if (typeof x === "number") return (x - y) * dir;
return String(x).localeCompare(String(y)) * dir;
});
}
Multiplying by dir flips the result rather than duplicating the comparator, and choosing the comparison from the value's type means one function handles every column.
toSorted matters here specifically: a table component that sorts its own props with sort() mutates the caller's array, so the "original order" option stops working after the first click.
A custom priority order
Sometimes the order is not alphabetical or numeric but a fixed list you decide, such as statuses:
const order = ["critical", "high", "medium", "low"];
tickets.toSorted((a, b) => order.indexOf(a.status) - order.indexOf(b.status));
indexOf turns each status into its position in your list, and the usual numeric subtraction does the rest.
One caution: an unrecognised status gives indexOf a -1, which sorts it before everything. If that is wrong, map missing values to a large number instead:
const rank = (s) => (order.indexOf(s) === -1 ? Infinity : order.indexOf(s));
tickets.toSorted((a, b) => rank(a.status) - rank(b.status));
For a longer list, a Map of status to rank avoids scanning the array on every comparison.
Three common mistakes
Using subtraction on strings. a.name - b.name is NaN, which is neither negative nor positive. The array comes back in an order that looks arbitrary and no error is raised. Strings need localeCompare.
Returning a boolean. (a, b) => a.age > b.age returns true or false, which convert to 1 and 0. There is no way to say "a comes first", so the sort is wrong in a way that looks nearly right on small arrays and clearly wrong on large ones. The comparator must return a negative number, not false.
Sorting props or state directly. items.sort(...) mutates the array you were given. In React that also means the reference does not change, so the component may not re-render even though the order did. toSorted or a spread fixes both problems at once.
Sorting nested and missing values
Real data is rarely flat, and two cases need handling before the comparator sees them.
A nested field is just a longer expression, and optional chaining keeps it safe:
users.toSorted((a, b) => (a.profile?.city ?? "").localeCompare(b.profile?.city ?? ""));
The ?? "" matters. Without it, a missing profile gives undefined, and calling localeCompare on undefined throws. Supplying an empty string groups the missing ones together at the start, which is usually what you want and is at least predictable.
Missing numbers need the same treatment for a different reason:
items.toSorted((a, b) => (a.price ?? 0) - (b.price ?? 0));
Without the default, undefined - 5 is NaN, and a comparator returning NaN puts the array in an unspecified order. Note this is different from an undefined element, which the engine moves to the end for you; an undefined property is passed straight to your comparator.
If missing values should sort last rather than first, use Infinity as the default instead of 0.
Sorting by date
The most common computed key, and the one where the data type matters most:
posts.toSorted((a, b) => new Date(b.date) - new Date(a.date)); // newest first
Subtracting two Date objects gives milliseconds, so the numeric idiom applies directly.
Two cautions. If date is already an ISO string such as 2026-09-08T10:00:00Z, you can compare the strings directly with localeCompare and get the correct order, because ISO format sorts lexicographically the same way it sorts chronologically. That is faster and avoids constructing dates.
If the format is anything else, such as 08/09/2026, string comparison is wrong and new Date() parsing of non-ISO formats varies between engines. Normalise to ISO where the data enters your system rather than working around the format at every sort.
Quick reference
arr.sort((a, b) => a.n - b.n) // number asc
arr.sort((a, b) => b.n - a.n) // number desc
arr.sort((a, b) => a.s.localeCompare(b.s)) // string
arr.sort((a, b) => a.x - b.x || a.y - b.y) // two keys
arr.toSorted(fn) // does not mutate
[...arr].sort(fn) // copy, then sort
arr.sort() // alphabetical, rarely right
Want to sort real data and watch the mutation happen? Start with the JavaScript track, or read removing duplicates from an array for the operation these lists usually need first.
More from the blog
range() in Python: start, stop, step, and why it is lazy
range(1000000) uses 24 bytes. A list of a thousand numbers uses 4,028. Here is what range actually is, measured.
Read moreHow to convert a string to a number in JavaScript
Converting a string to a number in JavaScript: an empty string is 0 with Number and NaN with parseInt. What four methods do to awkward input.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.