A good JavaScript feature removes something the reader used to work out by hand. Is this variable going to change? Does this default replace zero? Will sorting this array change the caller's data? The most useful additions make those answers visible in the code.
This is a selective look at ES6 through ES2024, with small examples and the mistakes each feature can still leave behind. The years refer to editions of the language standard, not a promise of support in every browser released that year. TC39 keeps the record of completed proposals and their publication years (opens in a new tab).
ES6 made intent easier to express
ES6, published in 2015, introduced modules, promises, block-scoped declarations, arrow functions, and destructuring. Together, these removed much of the setup that used to surround ordinary application code.
The smaller choices are often the ones that help most in a review. Use const
when a binding should not be reassigned and let when it should. Remember that
a constant binding does not freeze an object. Destructure the fields needed by a
function, but keep a long or fragile nested lookup in a named step.
function describeReport({ title, rowCount }) {
return `${title}: ${rowCount} rows`;
}
const report = { title: 'August sales', rowCount: 24 };
report.rowCount = 25; // const does not prevent this mutationArrow functions are convenient callbacks. They also capture this from the
surrounding scope, so replacing a normal function with an arrow can change
behavior. Shorter syntax alone is not a reason to rewrite a method that depends
on its receiver.
Async code still needs a concurrency decision
ES2017 added async and await. A sequence of dependent operations became
easier to read, but it also became easy to serialize requests accidentally. If
two requests are independent, start them together.
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
async function loadReportPage() {
const [reports, permissions] = await Promise.all([
getJson('/api/reports'),
getJson('/api/permissions'),
]);
return { reports, permissions };
}This assumes the page needs both results. Promise.all rejects if either
request fails, but it does not cancel the other request. If partial results are
useful, handle the outcomes separately. The
Promise specification (opens in a new tab)
defines the combined promise's behavior.
Do not apply the same pattern to an unbounded list of requests. A large batch may need a concurrency limit. Also notice the explicit status check: a fulfilled fetch request can still contain an HTTP error response.
Make data transformations explicit
Object rest and spread arrived in ES2018. They made ordinary updates easier to scan, especially when a function needs to leave its input unchanged. The copy is shallow: nested objects retain their identity unless you copy them too. The original proposal (opens in a new tab) includes this distinction.
const report = {
title: 'Sales',
filters: { region: 'North', month: '2024-08' },
};
const updated = {
...report,
filters: { ...report.filters, region: 'South' },
};
console.log(report.filters.region); // NorthES2019 added flatMap and Object.fromEntries, which cover another common
task: converting nested records into a lookup. Here is an example with synthetic
report data.
const groups = [
{ reports: [{ id: 'sales', title: 'Sales' }] },
{ reports: [{ id: 'returns', title: 'Returns' }] },
];
const titleById = Object.fromEntries(
groups.flatMap(group =>
group.reports.map(report => [report.id, report.title])
)
);
// { sales: 'Sales', returns: 'Returns' }This assumes IDs are unique. Duplicate keys would replace earlier values, so validate uniqueness if duplicates indicate a data error. A concise transformation still needs a clear input contract.
Choose what missing means
Optional chaining and nullish coalescing arrived in ES2020. They work well together, but they answer different questions. Optional chaining stops a lookup when a value is null or undefined. Nullish coalescing supplies a default for those two values.
const settings = { retryCount: 0 };
const a = settings.retryCount || 3; // 3
const b = settings.retryCount ?? 3; // 0
const theme = settings.appearance?.theme ?? 'light';The
nullish coalescing proposal (opens in a new tab)
explains why ?? preserves zero, false, and empty strings. ES2021's ??=
applies the same rule when assigning a default. Use ||= only when every falsy
value should be replaced.
There is an easy arithmetic trap here: NaN is not nullish. Adding ?? 0 after
a division does not repair missing inputs or division by zero.
function clickRate(metrics) {
const clicks = metrics?.clicks;
const impressions = metrics?.impressions;
if (!Number.isFinite(clicks) || !Number.isFinite(impressions)) {
return null;
}
return impressions > 0 ? clicks / impressions : null;
}In this example, null means the rate cannot be calculated. The UI can show an unavailable state instead of inventing a zero. If your domain defines missing data differently, make that decision explicit.
Keep initialization predictable
ES2022 added private class fields and top-level await. Private fields let a class enforce access to its internal state. They do not make a secret safe to ship to a browser; code running on a user's device cannot hide a service credential from that user.
Top-level await is useful when an ES module cannot provide its exports until initialization finishes. It also means modules that depend on it must wait. A configuration request at module scope can therefore delay more of the application than its author intended. The proposal's execution examples (opens in a new tab) show how that dependency works.
Keep optional startup work behind an explicit function call. For example, a report page should not wait for an optional analytics module merely because both import the same initialization file.
Copy an array without changing the original
ES2023 introduced toSorted, toReversed, toSpliced, and with. These
return a new array, which makes them useful when the caller owns the original.
See the
change-array-by-copy proposal (opens in a new tab)
for the method contracts.
const values = [20, 3, 100];
const ascending = values.toSorted((a, b) => a - b);
console.log(ascending); // [3, 20, 100]
console.log(values); // [20, 3, 100]The numeric comparator still matters; the default sort compares string representations. The copy is also shallow. Sorting an array of objects creates a new array, but changing a property on one of those objects still changes the shared object.
Group data without a custom reducer
ES2024 added Object.groupBy and Map.groupBy. They make a common operation
recognizable without reading a reducer's accumulator logic.
const reports = [
{ id: 'sales', status: 'ready' },
{ id: 'returns', status: 'running' },
{ id: 'stock', status: 'ready' },
];
const byStatus = Object.groupBy(reports, report => report.status);
console.log(byStatus.ready.map(report => report.id));
// ['sales', 'stock']Object.groupBy returns an object with a null prototype. Use Map.groupBy when
keys need to be objects or other values that should retain their identity.
Neither method clones the grouped records. These details are covered in the
grouping proposal (opens in a new tab).
Another ES2024 addition,
Promise.withResolvers (opens in a new tab),
returns a promise together with its resolve and reject functions. It helps when
completion is controlled outside the place that creates the promise, such as an
event adapter. An ordinary async function is usually clearer for a request that
already returns a promise.
Check the runtime before the refactor
Syntax support and API support are separate concerns. A compiler may transform
optional chaining for an older runtime. A built-in method such as
Object.groupBy needs runtime support or a suitable polyfill. A successful
build alone does not prove the deployed code will work in your supported
browsers.
I would adopt these features while changing the surrounding code, with tests for the behavior most likely to shift: missing values, zero, shared references, duplicate keys, and failed requests. That keeps the review focused on what the code does. A modern spelling is useful when the next reader can understand it more easily.
Continue reading
More from the journal.
Angular Architecture
6 min read
Building Angular Apps That Stay Easy to Change
Feature boundaries, state ownership, and rendering choices that keep a growing Angular application understandable.
Performance Engineering
7 min read
Web Performance: Find the Wait, Then Fix It
A practical method for finding slow user journeys, tracing the cause, and checking that a performance change actually helps.