I still review codebases in 2026 that use var everywhere, concatenate strings with +, and write fifteen lines of code to safely access a nested object property. ES6 landed over a decade ago. ES2020 features like optional chaining have been in every major browser for years. There's no excuse anymore.
This isn't a history lesson on ECMAScript versions. It's a practical rundown of the modern JavaScript features that will make your code shorter, safer, and easier to read — features you should be reaching for every single day.
Destructuring: Stop Writing data.user.name
Destructuring lets you unpack values from objects and arrays into individual variables. Once you start using it, going back to the old way physically hurts.
Object Destructuring
// Before: tedious and repetitive
const name = user.name;
const email = user.email;
const role = user.role;
// After: one line
const { name, email, role } = user;
You can rename variables during destructuring, which is essential when property names would conflict:
const { name: userName, email: userEmail } = user;
const { name: companyName } = company;
// userName, userEmail, companyName — no conflicts
You can also set defaults for when a property is missing:
const { theme = 'light', language = 'en' } = userPreferences;
// If userPreferences.theme is undefined, theme defaults to 'light'
Array Destructuring
const [first, second, ...rest] = [10, 20, 30, 40, 50]; // first = 10, second = 20, rest = [30, 40, 50] // Swap variables without a temp variable let a = 1, b = 2; [a, b] = [b, a]; // a = 2, b = 1
Where this really shines is function parameters. Instead of accepting a single options object and accessing properties with options.width, destructure right in the parameter list:
// Clean function signature with defaults
function createThumbnail({ width = 200, height = 200, quality = 80 } = {}) {
console.log(`Creating ${width}x${height} thumbnail at ${quality}% quality`);
}
createThumbnail({ width: 400 });
// "Creating 400x200 thumbnail at 80% quality"
Spread and Rest: Three Dots, Infinite Uses
The ... operator does two things depending on context. When you spread, you're expanding something. When you rest, you're collecting things.
Spread for Objects
const defaults = { theme: 'light', fontSize: 14, lang: 'en' };
const userSettings = { theme: 'dark', fontSize: 18 };
// Merge objects — later properties override earlier ones
const config = { ...defaults, ...userSettings };
// { theme: 'dark', fontSize: 18, lang: 'en' }
// Shallow clone an object
const copy = { ...original };
// Add/override a single property immutably
const updated = { ...user, lastLogin: new Date() };
I use this pattern constantly in React state management and API handlers. It's clean, immutable, and readable.
Spread for Arrays
const frontend = ['React', 'Vue', 'Svelte'];
const backend = ['Node', 'Deno', 'Bun'];
// Combine arrays
const fullStack = [...frontend, ...backend];
// Clone an array
const copy = [...original];
// Convert NodeList to array (still useful in DOM manipulation)
const elements = [...document.querySelectorAll('.card')];
Rest Parameters
function log(level, ...messages) {
messages.forEach(msg => console[level](msg));
}
log('warn', 'Disk space low', 'Only 2GB remaining');
// console.warn('Disk space low')
// console.warn('Only 2GB remaining')
Rest replaces the old arguments object, and it's better in every way — it gives you a real array, not an array-like object, and it's explicit about which parameters are collected.
Template Literals: String Concatenation Is Dead
If you're still writing "Hello, " + name + "! You have " + count + " notifications.", please stop. Template literals have been available since 2015.
const message = `Hello, ${name}! You have ${count} notifications.`;
// Multi-line strings — no more \n or array.join
const html = `
<div class="card">
<h2>${title}</h2>
<p>${description}</p>
<span>${formatDate(date)}</span>
</div>
`;
// Expressions inside interpolations
const status = `Status: ${isActive ? 'Active' : 'Inactive'}`;
const price = `Total: $${(quantity * unitPrice).toFixed(2)}`;
Tagged template literals are an underused power feature. Libraries like styled-components, lit-html, and graphql-tag all use them. The tag function receives the string parts and interpolated values separately, letting you do custom processing — escaping HTML, building SQL queries safely, or constructing typed queries.
Optional Chaining: No More && Chains
This is the feature that saved the most lines of code in my career. Before optional chaining, accessing deeply nested properties was a minefield:
// The old way — defensive && chaining const city = user && user.address && user.address.city; // Slightly better but still ugly const city = user ? (user.address ? user.address.city : undefined) : undefined; // The modern way const city = user?.address?.city;
If any part of the chain is null or undefined, the entire expression short-circuits to undefined. No error. No crash. Just undefined.
It works with methods and bracket notation too:
// Optional method calls const result = api.getData?.(); // calls getData if it exists // Optional bracket notation const value = config?.[dynamicKey]; // Combine with array access const firstTag = post?.tags?.[0];
I use optional chaining on every API response. You never know when a backend team decides to make a field nullable without telling you.
Nullish Coalescing: Better Defaults
The || operator for default values has a problem: it triggers on any falsy value, including 0, "", and false. That's often not what you want.
// Problem with || const count = serverResponse.count || 10; // If count is 0, this returns 10. That's wrong. const name = formData.name || 'Anonymous'; // If someone submits an empty string intentionally, this overwrites it. // Solution: nullish coalescing (??) const count = serverResponse.count ?? 10; // Only falls back to 10 if count is null or undefined. 0 stays as 0. const name = formData.name ?? 'Anonymous'; // Empty string stays as empty string.
Pair it with optional chaining for incredibly concise and safe property access:
const theme = user?.preferences?.theme ?? 'light'; // Safely navigates the chain, defaults to 'light' only if the result is null/undefined
Use ?? when 0, empty string, or false are valid values. Use || when you want to fall back on any falsy value. Mixing them up is one of the most common source of bugs I see in code reviews.
ES Modules: import/export Done Right
CommonJS (require/module.exports) was Node's module system for over a decade. ES modules (import/export) are the standard now, and they work everywhere — browsers, Node.js, Deno, and Bun.
// math.js — named exports
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// app.js — named imports
import { add, multiply, PI } from './math.js';
// Import everything under a namespace
import * as math from './math.js';
console.log(math.add(2, 3));
// Default export (one per module)
// logger.js
export default class Logger {
log(msg) { console.log(`[LOG] ${msg}`); }
}
// app.js
import Logger from './logger.js';
Key differences from CommonJS that catch people:
- ES modules are statically analyzed — imports are resolved before code runs. No conditional imports at the top level (use dynamic
import()for that). - ES modules are strict mode by default — no need for
"use strict". - Imports are live bindings — if the exporting module changes a value, the importing module sees the change. CommonJS copies the value.
Dynamic Imports
When you need to load a module conditionally or lazily:
// Load a heavy module only when needed
async function renderChart(data) {
const { Chart } = await import('./chart-library.js');
const chart = new Chart(data);
chart.render('#chart-container');
}
// Conditional loading
const locale = navigator.language.startsWith('fr')
? await import('./locales/fr.js')
: await import('./locales/en.js');
Dynamic imports return promises, so they work naturally with async/await. Every bundler (Webpack, Vite, Rollup) uses dynamic imports as code-splitting boundaries — call import() and the bundler automatically creates a separate chunk.
Honorable Mentions
A few more features that deserve a spot in your daily toolkit:
Object.entries() and Object.fromEntries()
const prices = { apple: 1.5, banana: 0.75, cherry: 3.0 };
// Transform object values
const discounted = Object.fromEntries(
Object.entries(prices).map(([fruit, price]) => [fruit, price * 0.9])
);
// { apple: 1.35, banana: 0.675, cherry: 2.7 }
Array.prototype.at()
const colors = ['red', 'green', 'blue']; colors.at(-1); // 'blue' — negative indexing, finally! colors.at(-2); // 'green'
structuredClone()
const original = { name: 'test', nested: { deep: true }, date: new Date() };
const clone = structuredClone(original);
// True deep clone — handles dates, maps, sets, typed arrays, and more
// No more JSON.parse(JSON.stringify(obj)) hacks
Share Beautiful Code Snippets
Turn your JavaScript code into stunning, shareable images. Perfect for tutorials, documentation, and social media posts.
Try Code to Image →Modern JavaScript isn't just syntactic sugar. Optional chaining prevents runtime crashes. Nullish coalescing prevents subtle data bugs. Destructuring makes function signatures self-documenting. ES modules enable tree-shaking that shrinks your bundles.
If your codebase is still stuck in ES5 patterns, you're not just writing more code — you're writing buggier, harder-to-maintain code. Pick one feature from this list, start using it in your next commit, and build from there. Your future self (and your code reviewers) will thank you.