Skip to contentNew book: Designing Human-Centered AI InterfacesGet notified
Abhishek Anand

JavaScript//7 min read

JavaScript from ES6 to ES2024: What Actually Helps

A practical look at modern JavaScript: clearer async code, safer defaults, and data transformations with fewer surprises.

Abhishek Anand

Abhishek Anand

UX Engineer at Google

JavaScript · ES2024 · Web Development · Frontend

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.

Make the function's inputs visible
function describeReport({ title, rowCount }) {
  return `${title}: ${rowCount} rows`;
}

const report = { title: 'August sales', rowCount: 24 };
report.rowCount = 25; // const does not prevent this mutation

Arrow 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.

Load two independent resources
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.

Copy the part of the object that changes
const report = {
  title: 'Sales',
  filters: { region: 'North', month: '2024-08' },
};

const updated = {
  ...report,
  filters: { ...report.filters, region: 'South' },
};

console.log(report.filters.region); // North

ES2019 added flatMap and Object.fromEntries, which cover another common task: converting nested records into a lookup. Here is an example with synthetic report data.

Build a lookup from nested records
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.

Preserve a valid zero
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.

Check the inputs before dividing
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.

Sort numbers without mutating the source
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.

Group reports by status
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.

All writing