Frontend

JavaScript ES6+ Features Cheatsheet

Mayur Dabhi
Mayur Dabhi
April 12, 2026
15 min read

You open an older file in a project and find var self = this, three levels of nested callbacks, and a string built from twelve + signs. Then you open a newer file and it's all ?., ??, destructured parameters and async functions. Both are JavaScript, and most codebases I work in have a mix of the two. If you only half know the newer syntax, reviewing that code (or debugging it) gets slow.

This is the cheatsheet I'd want next to me for that: the ES6+ features that actually show up in day-to-day code, with a note on when each one helps and where it bites.

Do You Still Need Babel?

For most of this, no. Current browsers and any maintained Node.js version run everything here natively. The newest additions are the exception: Object.groupBy needs Node 21+ and Promise.withResolvers needs Node 22+, so check your runtime (or your browserslist targets) before using them.

Variables: let, const, and Why You Should Ditch var

Block-scoped variables were the most basic change in ES6, and the reason they matter only makes sense once you've been bitten by function-scoped var.

The Problem with var

var hoisting pitfalls
// var is hoisted and function-scoped, which causes bugs
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3  (not 0, 1, 2!)

// let is block-scoped, so each iteration gets its own i
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2

// const: use for values that should never be reassigned
const API_URL = 'https://api.example.com';
const config = { timeout: 5000, retries: 3 };

// const prevents reassignment but NOT mutation
config.timeout = 10000; // This works
// config = {};          // TypeError: Assignment to constant variable
Feature var let const
Scope Function Block Block
Hoisting Yes (initialized as undefined) Yes (in TDZ, not accessible) Yes (in TDZ, not accessible)
Re-declarable Yes No No
Re-assignable Yes Yes No
Use today? No When value changes Default choice

Rule of thumb: Default to const. Only use let when you know the variable will be reassigned (loop counters, accumulator variables). Never use var.

The TDZ (temporal dead zone) is where people get confused. Touch a let or const before its declaration line and you get ReferenceError: Cannot access 'x' before initialization, not undefined. If you see that error, it's usually a function reading a variable declared further down the same module.

Arrow Functions: Concise Syntax and Lexical this

Most people pick up arrow functions for the shorter syntax, but the bigger difference is this. An arrow function doesn't get its own this; it uses the one from the surrounding scope (lexical this). That's why the old var self = this trick disappeared from most codebases.

Arrow function syntax and this behavior
// Traditional function
const double = function(n) { return n * 2; };

// Arrow function (equivalent)
const double = (n) => n * 2;

// Multiple params: parentheses required
const add = (a, b) => a + b;

// No params: empty parens required
const getTimestamp = () => Date.now();

// Multi-line body: curly braces + explicit return
const processUser = (user) => {
    const name = user.name.trim();
    return { ...user, name };
};

// Returning an object literal? Wrap it in parens, or {} is read as a block
const toPoint = (x, y) => ({ x, y });

// Lexical 'this': the key difference
class Timer {
    constructor() {
        this.seconds = 0;
    }

    start() {
        // Arrow function captures 'this' from start() method
        setInterval(() => {
            this.seconds++; // 'this' correctly refers to Timer instance
            console.log(this.seconds);
        }, 1000);
    }

    startBroken() {
        // Regular function: 'this' is NOT the Timer here
        // (it's window in browsers, a Timeout object in Node)
        setInterval(function() {
            this.seconds++; // no error, just NaN, which makes it hard to spot
        }, 1000);
    }
}
When NOT to Use Arrow Functions

Don't use them as object methods: in const obj = { greet: () => this.name }, this won't be the object. They also can't be constructors (new throws a TypeError) and they have no arguments object. For DOM event handlers where you want this to be the element, stick with a regular function.

Destructuring: Unpacking Arrays and Objects

Writing const name = user.name; const role = user.role; gets old fast. Destructuring pulls values out of objects and arrays in one statement, and it also tells the reader up front which fields a function actually uses. It's handy for swapping variables and for options objects in function parameters too.

Object and array destructuring
// === OBJECT DESTRUCTURING ===
const user = { id: 1, name: 'Alice', role: 'admin', age: 30 };

// Basic extraction
const { name, role } = user;
console.log(name); // 'Alice'

// Rename while destructuring
const { name: userName, role: userRole } = user;
console.log(userName); // 'Alice'

// Default values
const { name, department = 'Engineering' } = user;
console.log(department); // 'Engineering' (not in user object)

// Nested destructuring
const response = {
    status: 200,
    data: { user: { id: 42, email: 'alice@example.com' } }
};
const { data: { user: { email } } } = response;
console.log(email); // 'alice@example.com'

// Rest in objects
const { id, ...rest } = user;
console.log(rest); // { name: 'Alice', role: 'admin', age: 30 }


// === ARRAY DESTRUCTURING ===
const colors = ['red', 'green', 'blue', 'yellow'];

// Basic extraction
const [first, second] = colors;
console.log(first);  // 'red'
console.log(second); // 'green'

// Skip elements with commas
const [, , third] = colors;
console.log(third); // 'blue'

// Rest in arrays
const [head, ...tail] = colors;
console.log(tail); // ['green', 'blue', 'yellow']

// Swap variables without a temp variable
let a = 1, b = 2;
[a, b] = [b, a];
console.log(a, b); // 2, 1

// Destructuring function return values
function getMinMax(arr) {
    return [Math.min(...arr), Math.max(...arr)];
}
const [min, max] = getMinMax([3, 1, 4, 1, 5, 9, 2]);


// === FUNCTION PARAMETER DESTRUCTURING ===
// Instead of: function render(options) { const title = options.title; ... }
function render({ title, subtitle = 'No subtitle', theme = 'light' }) {
    console.log(title, subtitle, theme);
}

render({ title: 'Hello', theme: 'dark' });
// 'Hello', 'No subtitle', 'dark'

Spread and Rest: The Three Dots

The same ... does two opposite jobs. Spread expands an iterable (or an object's properties) into individual items. Rest gathers leftover items into an array. You'll use spread constantly for copying and merging, and rest for functions that take a variable number of arguments.

One gotcha: spread copies are shallow. { ...state } gives you a new top-level object, but nested objects are still shared, so mutating copy.user.name also changes the original. If you need a real deep copy, use structuredClone().

Spread and rest operators
// === SPREAD OPERATOR ===

// Clone arrays (shallow)
const original = [1, 2, 3];
const clone = [...original];
clone.push(4); // original is unchanged

// Merge arrays
const nums = [1, 2, 3];
const moreNums = [4, 5, 6];
const all = [...nums, ...moreNums, 7, 8]; // [1,2,3,4,5,6,7,8]

// Clone objects (shallow)
const user = { name: 'Alice', role: 'admin' };
const userClone = { ...user };

// Merge/override objects (later keys win)
const defaults = { theme: 'light', lang: 'en', fontSize: 14 };
const userPrefs = { theme: 'dark', fontSize: 16 };
const config = { ...defaults, ...userPrefs };
// { theme: 'dark', lang: 'en', fontSize: 16 }

// Immutable state updates (React pattern)
const state = { count: 0, user: null, loading: false };
const newState = { ...state, count: state.count + 1, loading: true };

// Spread into function call
function sum(a, b, c) { return a + b + c; }
const args = [1, 2, 3];
console.log(sum(...args)); // 6


// === REST PARAMETERS ===

// Collect remaining arguments
function log(level, ...messages) {
    console.log(`[${level}]`, messages.join(' '));
}
log('INFO', 'Server', 'started', 'on', 'port', '3000');
// [INFO] Server started on port 3000

// Better than the arguments object: it's a real array
function sum(...nums) {
    return nums.reduce((acc, n) => acc + n, 0);
}
sum(1, 2, 3, 4, 5); // 15
ES6+ JavaScript let / const Arrow Functions Destructuring Spread / Rest Template Literals Promises/Async Modules Classes Map / Set Optional Chaining

The ES6+ features covered here. All of them run natively in current browsers.

Template Literals and Tagged Templates

Backtick strings fix the 'Hello, ' + name + '!' mess and let you write multi-line strings without \n. The less obvious part is tagged templates: a function that receives the static string parts and the interpolated values separately. That's how styled-components, gql and several SQL libraries handle dynamic values safely.

Template literals
const name = 'Alice';
const role = 'admin';

// ES5: painful concatenation
const msg = 'Hello, ' + name + '! You are logged in as ' + role + '.';

// ES6 template literal
const msg = `Hello, ${name}! You are logged in as ${role}.`;

// Multi-line strings, no \n needed
const html = `
    <div class="user-card">
        <h2>${name}</h2>
        <span>${role}</span>
    </div>
`;

// Expressions inside ${}
const price = 29.99;
const tax = 0.08;
const receipt = `
    Subtotal: $${price.toFixed(2)}
    Tax:      $${(price * tax).toFixed(2)}
    Total:    $${(price * (1 + tax)).toFixed(2)}
`;

// Tagged templates
function highlight(strings, ...values) {
    return strings.reduce((acc, str, i) => {
        const val = values[i] !== undefined
            ? `<mark>${values[i]}</mark>`
            : '';
        return acc + str + val;
    }, '');
}

const searchTerm = 'JavaScript';
const result = highlight`Learn ${searchTerm} in 2026`;
// 'Learn <mark>JavaScript</mark> in 2026'

// Safe SQL queries with tagged templates (prevents injection)
function sql(strings, ...values) {
    const query = strings.join('?');
    return { query, params: values };
}
const userId = 42;
const { query, params } = sql`SELECT * FROM users WHERE id = ${userId}`;

Promises and Async/Await

Almost every API call, file read or database query in JavaScript is async. ES6 Promises replaced nested callbacks, and ES2017's async/await lets you write that code top to bottom while it still runs non-blocking. Under the hood await is still Promises, so you need both.

Promises and async/await patterns
// === PROMISES ===

// Creating a Promise
function fetchUser(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (id > 0) {
                resolve({ id, name: 'Alice' });
            } else {
                reject(new Error('Invalid user ID'));
            }
        }, 100);
    });
}

// Consuming with .then() / .catch()
fetchUser(1)
    .then(user => console.log(user.name))
    .catch(err => console.error(err.message))
    .finally(() => console.log('Done'));

// Promise.all: run in parallel, rejects as soon as one fails
const [user, posts, comments] = await Promise.all([
    fetchUser(1),
    fetchPosts(1),
    fetchComments(1)
]);

// Promise.allSettled: wait for all, don't fail fast
const results = await Promise.allSettled([
    fetchUser(1),
    fetchUser(-1), // will reject
]);
results.forEach(r => {
    if (r.status === 'fulfilled') console.log(r.value);
    else console.error(r.reason);
});

// Promise.race: first one to settle wins
const fastest = await Promise.race([fetch('/api/a'), fetch('/api/b')]);


// === ASYNC / AWAIT ===

// async function always returns a Promise
async function loadDashboard(userId) {
    try {
        // await pauses execution until Promise resolves
        const user = await fetchUser(userId);
        const [posts, notifications] = await Promise.all([
            fetchPosts(user.id),
            fetchNotifications(user.id),
        ]);

        return { user, posts, notifications };
    } catch (error) {
        // Errors from rejected Promises land here
        console.error('Failed to load dashboard:', error.message);
        throw error; // re-throw if you want caller to handle
    }
}

// Top-level await (ES2022, works in modules)
const data = await loadDashboard(1);
console.log(data.user.name);
await Inside forEach Doesn't Wait

items.forEach(async (item) => { await save(item); }) fires every callback and returns immediately, so code after it runs before anything is saved, and rejections can surface as unhandled. Use for...of with await if the calls must run in order, or await Promise.all(items.map(save)) if they can run in parallel.

ES6 Modules: import and export

Before ES modules you had script-tag order, IIFEs, or CommonJS require in Node. ES modules are the standard both browsers and Node now understand. Imports and exports are static, so bundlers can see what's unused and drop it from the bundle (tree-shaking).

In Node, the first error most people hit is SyntaxError: Cannot use import statement outside a module. Node treats .js files as CommonJS unless your package.json has "type": "module" or the file uses the .mjs extension. Also remember that relative imports in Node need the file extension ('./math.js', not './math').

ES Modules: import and export
// === EXPORTING ===

// math.js: named exports (multiple per file)
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// Can also export at the bottom
const subtract = (a, b) => a - b;
export { subtract };

// Default export (one per file, usually the main thing)
// user.js
export default class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
    greet() { return `Hi, I'm ${this.name}`; }
}

// Re-exporting (barrel files / index.js pattern)
// utils/index.js
export { add, multiply, subtract } from './math.js';
export { default as User } from './user.js';


// === IMPORTING ===

// Named imports must match the export name
import { add, multiply } from './math.js';

// Rename on import
import { add as sum, multiply as product } from './math.js';

// Default import: you choose the name
import User from './user.js';

// Import all named exports as namespace
import * as MathUtils from './math.js';
MathUtils.add(2, 3); // 5

// Import default AND named together
import User, { PI } from './utils/index.js';

// Dynamic import loads a module on demand (code splitting)
const { default: Chart } = await import('./chart.js');
const chart = new Chart(canvas);

// Conditional dynamic import
if (userPreference === 'dark') {
    const { darkTheme } = await import('./themes.js');
    applyTheme(darkTheme);
}

Classes, Inheritance, and Private Fields

If you've come from PHP or Java, ES6 classes will look familiar, but they're still prototypes underneath. class is a nicer way to write what used to be constructor functions plus Foo.prototype.method = .... ES2022 added real private fields with a # prefix, so you no longer have to rely on the _field naming convention and hope nobody touches it.

ES6+ Classes with private fields
class Animal {
    // Private fields: not accessible outside the class
    #name;
    #sound;

    // Static field
    static count = 0;

    constructor(name, sound) {
        this.#name = name;
        this.#sound = sound;
        Animal.count++;
    }

    // Getter: access it like a property
    get name() { return this.#name; }

    // Method
    speak() {
        return `${this.#name} says ${this.#sound}!`;
    }

    // Static method: called on the class, not an instance
    static getCount() {
        return `${Animal.count} animals created`;
    }
}

// Inheritance with extends
class Dog extends Animal {
    #breed;

    constructor(name, breed) {
        super(name, 'Woof'); // must call super() before 'this'
        this.#breed = breed;
    }

    // Override parent method
    speak() {
        return `${super.speak()} (${this.#breed})`;
    }

    fetch(item) {
        return `${this.name} fetches the ${item}!`;
    }
}

const dog = new Dog('Rex', 'Labrador');
console.log(dog.speak());   // 'Rex says Woof! (Labrador)'
console.log(dog.fetch('ball')); // 'Rex fetches the ball!'
console.log(Animal.getCount()); // '1 animals created'

// dog.#name;  // SyntaxError: private field not accessible

Map, Set, WeakMap, WeakSet

Plain objects work as dictionaries until you try to use an object as a key and find every key turned into the string "[object Object]". Map fixes that: any value can be a key, insertion order is kept, and .size is built in. Set holds unique values. WeakMap and WeakSet hold their keys weakly, so an entry doesn't stop its key object from being garbage collected (useful for caching data per DOM node or per object).

Map and Set
// === MAP ===
const userRoles = new Map();

// Any type as key, even objects
const alice = { id: 1, name: 'Alice' };
userRoles.set(alice, 'admin');
userRoles.set('guestToken', 'viewer');

console.log(userRoles.get(alice));       // 'admin'
console.log(userRoles.has('guestToken')); // true
console.log(userRoles.size);              // 2

// Iteration
for (const [key, value] of userRoles) {
    console.log(key, '->', value);
}

// Create from entries
const config = new Map([
    ['host', 'localhost'],
    ['port', 3000],
    ['debug', true],
]);

// Convert to/from object
const obj = Object.fromEntries(config);
const map = new Map(Object.entries(obj));


// === SET ===
const tags = new Set(['js', 'react', 'js', 'node', 'react']);
console.log([...tags]); // ['js', 'react', 'node'] (duplicates removed)

tags.add('typescript');
tags.delete('node');
console.log(tags.has('react')); // true

// Remove duplicates from array
const arr = [1, 2, 2, 3, 3, 3, 4];
const unique = [...new Set(arr)]; // [1, 2, 3, 4]

// Set operations
const a = new Set([1, 2, 3, 4]);
const b = new Set([3, 4, 5, 6]);

const union        = new Set([...a, ...b]);         // {1,2,3,4,5,6}
const intersection = new Set([...a].filter(x => b.has(x))); // {3,4}
const difference   = new Set([...a].filter(x => !b.has(x))); // {1,2}

Optional Chaining and Nullish Coalescing

TypeError: Cannot read properties of undefined (reading 'city') is probably the most common JavaScript error in production logs. These two ES2020 operators cut most of the defensive checks that prevent it. Optional chaining (?.) stops and returns undefined when the value before it is null or undefined. Nullish coalescing (??) falls back to a default only for null/undefined, while || also replaces 0, false and empty strings.

Optional chaining and nullish coalescing
const user = {
    profile: {
        address: {
            city: 'Mumbai'
        }
    },
    getName: () => 'Alice',
    scores: [95, 87, 92]
};

// === OPTIONAL CHAINING (?.) ===

// Without optional chaining: verbose and easy to get wrong
const city = user && user.profile && user.profile.address
    ? user.profile.address.city
    : undefined;

// With optional chaining
const city = user?.profile?.address?.city; // 'Mumbai'
const zip  = user?.profile?.address?.zip;  // undefined (no error)

// On methods
const name = user?.getName?.();   // 'Alice'
const age  = user?.getAge?.();    // undefined (method doesn't exist)

// On arrays
const firstScore = user?.scores?.[0]; // 95
const score4     = user?.scores?.[3]; // undefined


// === NULLISH COALESCING (??) ===

// ?? only triggers on null or undefined
const port = process.env.PORT ?? 3000;
// Careful: env vars are strings. PORT='' stays '' with ??, and || gives 3000

const config = {
    timeout: 0,       // valid value
    retries: null,    // explicitly unset
    debug: false,     // valid value
};

console.log(config.timeout  ?? 5000); // 0    (not 5000)
console.log(config.retries  ?? 3);    // 3    (was null)
console.log(config.debug    ?? true); // false (not true)

// Combining both
const displayName = user?.profile?.displayName ?? user?.getName?.() ?? 'Anonymous';

// Nullish assignment (??=): assign only if null/undefined
config.retries ??= 3; // sets retries to 3 since it was null
config.timeout ??= 5000; // doesn't change, 0 is not null/undefined

ES6+ Quick Reference: Features by Year

Feature ES Version Year
let, const, Arrow Functions, Classes, Promises, Destructuring, Spread/Rest, Template Literals, Modules, Map/Set, SymbolES6 / ES20152015
Object.values/entries, String padding, Object.getOwnPropertyDescriptorsES20172017
async/awaitES20172017
Object spread, Promise.finally, for-await-ofES20182018
Array flat/flatMap, Object.fromEntries, Optional catch bindingES20192019
Optional chaining (?.), Nullish coalescing (??), Promise.allSettled, BigInt, globalThisES20202020
Logical assignment (??=, &&=, ||=), Numeric separators (1_000_000), Promise.any, String replaceAllES20212021
Private class fields (#), Top-level await, Object.hasOwn, Array.at(-1), Error causeES20222022
Array groupBy (Object.groupBy), Promise.withResolvers, Hashbang grammarES20242024

Putting It All Together: Real-World Patterns

In real code these features show up together. Here's a small fetch wrapper of the kind most projects end up with, using most of what's above: a custom error class with a private field, a destructured options object with defaults, spread for headers, AbortController for a timeout, and named exports.

Real-world ES6+ combined patterns
// api.js: a small API client using ES6+ features

const BASE_URL = 'https://api.example.com';

class ApiError extends Error {
    #statusCode;

    constructor(message, statusCode) {
        super(message);
        this.name = 'ApiError';
        this.#statusCode = statusCode;
    }

    get statusCode() { return this.#statusCode; }
}

async function request(endpoint, {
    method = 'GET',
    body,
    headers = {},
    timeout = 5000,
} = {}) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    try {
        const response = await fetch(`${BASE_URL}${endpoint}`, {
            method,
            headers: {
                'Content-Type': 'application/json',
                ...headers,
            },
            body: body ? JSON.stringify(body) : undefined,
            signal: controller.signal,
        });

        if (!response.ok) {
            throw new ApiError(
                `Request failed: ${response.statusText}`,
                response.status
            );
        }

        return await response.json();
    } finally {
        clearTimeout(timeoutId);
    }
}

// Named exports (tree-shakeable)
export const get  = (url, opts) => request(url, { ...opts, method: 'GET' });
export const post = (url, body, opts) => request(url, { ...opts, method: 'POST', body });
export const put  = (url, body, opts) => request(url, { ...opts, method: 'PUT', body });
export const del  = (url, opts) => request(url, { ...opts, method: 'DELETE' });


// Usage with optional chaining + nullish coalescing
import { get, post } from './api.js';

async function loadUserProfile(userId) {
    const [user, posts] = await Promise.all([
        get(`/users/${userId}`),
        get(`/users/${userId}/posts`),
    ]);

    return {
        name: user?.profile?.displayName ?? user?.name ?? 'Anonymous',
        bio: user?.profile?.bio ?? '',
        postCount: posts?.length ?? 0,
        latestPost: posts?.at(-1)?.title ?? 'No posts yet',
    };
}

Mistakes I See Most Often (and When Not to Reach for the Shiny Syntax)

What to watch for in code review

  • Optional chaining everywhere. a?.b?.c?.d on data that should always exist hides bugs instead of surfacing them. If user must be there, let it throw so you find out why it wasn't.
  • Assuming spread is a deep copy. It isn't. Nested objects are shared, and in React that means mutating state you thought you'd copied.
  • Sequential awaits that could be parallel. Two independent await fetch() lines run one after the other. Wrap them in Promise.all unless the second depends on the first.
  • Arrow functions as object or prototype methods. this won't be what you expect. Use method shorthand (greet() {}) instead.
  • Default exports in shared utility modules. Each import can rename them, which makes search and refactoring harder. I prefer named exports there and keep default exports for things like React components or a single class per file.

A few places where the older style is still fine or better: a plain for loop is often clearer than a chain of map/filter/reduce with spreads inside it, and spreading inside a reduce over a big array copies the accumulator every iteration, which gets slow. Classes are worth it when you have real state and behaviour together; for a bag of helper functions, a module with named exports is simpler. And if you have to support an old runtime (an outdated Node on a legacy server, an embedded webview), check before using the ES2022+ features in the table above, or let your build step transpile them.

JavaScript ES6 Features Modern JS Frontend Arrow Functions Async/Await
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.