Home JavaScript ES6+ Features Explained — The Why, When, and Real-World How

ES6+ Features Explained — The Why, When, and Real-World How

In Plain English 🔥
Imagine you used to pack for a trip by laying every single shirt, sock, and shoe out one by one, naming each item out loud before putting it in the bag. ES6+ is like getting a smart packing organizer — it lets you grab a whole outfit at once, label compartments automatically, and even pack for tomorrow's trip while you sleep. It doesn't change what you're doing (writing JavaScript), it just removes the tedious, error-prone busywork so you can focus on actually building things.
⚡ Quick Answer
Imagine you used to pack for a trip by laying every single shirt, sock, and shoe out one by one, naming each item out loud before putting it in the bag. ES6+ is like getting a smart packing organizer — it lets you grab a whole outfit at once, label compartments automatically, and even pack for tomorrow's trip while you sleep. It doesn't change what you're doing (writing JavaScript), it just removes the tedious, error-prone busywork so you can focus on actually building things.

JavaScript before ES6 was like cooking in a kitchen where you had to make every single utensil by hand before you could start the recipe. You could do it — millions of developers did — but an enormous chunk of your day was fighting ceremony instead of solving real problems. ES6 (released in 2015) and the yearly spec updates that followed (ES7, ES8... collectively called ES6+) rewired how modern JavaScript is written. Every production codebase you'll encounter today — React apps, Node APIs, browser extensions — is built on these features.

let & const vs var — Why Block Scope Changed Everything

Before ES6, var was the only way to declare a variable. The problem? var is function-scoped, not block-scoped. That means a variable declared inside an if block leaks out into the surrounding function. In large codebases this causes bugs that are genuinely hard to trace — you change a variable inside a loop, and suddenly something outside the loop has a different value than you expected.

let and const brought block scoping to JavaScript. A variable declared with let or const inside curly braces {} lives and dies inside those braces. Nothing outside can see it. const goes one step further — it prevents reassignment of the binding itself, which makes your intent clear: 'this value should not change.'

Use const by default. Reach for let only when you know you'll reassign (like a loop counter or an accumulator). Avoid var in new code entirely — there's no modern scenario where var is the better choice.

blockScope.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334
// ----- THE VAR PROBLEM -----
function calculateDiscount_OLD(price) {
  if (price > 100) {
    var discount = 20; // declared inside the if-block
  }
  // 'discount' leaks OUT of the if-block because var is function-scoped
  console.log('var discount outside block:', discount); // 20 (!) — or undefined if price <= 100
}
calculateDiscount_OLD(150);

// ----- THE let FIX -----
function calculateDiscount_NEW(price) {
  if (price > 100) {
    let discount = 20; // block-scoped — stays inside the if-block
  }
  // This line would throw: ReferenceError: discount is not defined
  // console.log(discount); // safely commented out — the error IS the feature!
  console.log('let prevents the accidental leak — discount is not visible here');
}
calculateDiscount_NEW(150);

// ----- const FOR VALUES THAT SHOULD NOT CHANGE -----
const TAX_RATE = 0.08; // Tax rate won't change during the program
const itemPrice = 49.99;
const totalPrice = itemPrice + itemPrice * TAX_RATE;
console.log('Total with tax:', totalPrice.toFixed(2)); // 53.99

// TAX_RATE = 0.10; // Uncommenting this throws: TypeError: Assignment to constant variable

// ----- IMPORTANT: const with objects -----
const userProfile = { name: 'Alice', role: 'admin' };
userProfile.role = 'editor'; // This IS allowed — we're mutating the object, not rebinding the variable
console.log('Updated role:', userProfile.role); // editor
// userProfile = {}; // THIS would throw — you can't rebind the const variable itself
▶ Output
var discount outside block: 20
let prevents the accidental leak — discount is not visible here
Total with tax: 53.99
Updated role: editor
⚠️
Watch Out: const Doesn't Mean Immutableconst prevents you from reassigning the variable to a new value, but it does NOT freeze the object or array at that variable. You can still push to a const array or change a const object's properties. If you need a truly immutable object, use Object.freeze() — but know that freeze is only one level deep.

Arrow Functions and Destructuring — Less Noise, More Signal

Arrow functions (=>) aren't just a shorter way to write a function — they deliberately don't bind their own this. In traditional functions, this depends on how the function is called, which is why you'd see code like var self = this; or .bind(this) everywhere. Arrow functions inherit this from the surrounding lexical scope, eliminating an entire class of confusing bugs.

Destructuring is the other daily-use feature that transforms how readable your code is. Instead of writing const userName = user.name; const userAge = user.age; on separate lines, you extract multiple values from an object or array in a single, expressive line. It reads almost like English: 'from this user object, give me the name and age.'

These two features combine constantly in real code — you'll see arrow functions as array callbacks and destructuring in function parameters. Learning them together is the fastest path to reading and writing modern JavaScript fluently.

arrowAndDestructure.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// ----- THE `this` PROBLEM ARROW FUNCTIONS SOLVE -----
const timer = {
  message: 'Time is up!',

  // Old-style function: `this` depends on how the function is called
  startOld: function () {
    setTimeout(function () {
      // Inside a regular callback, `this` is no longer the timer object
      // In strict mode it's undefined; in browsers it's the window object
      console.log('Old way — this.message:', this.message); // undefined (or crash)
    }, 100);
  },

  // Arrow function: `this` is inherited from startNew's scope (the timer object)
  startNew: function () {
    setTimeout(() => {
      console.log('Arrow way — this.message:', this.message); // 'Time is up!'
    }, 100);
  },
};
timer.startOld();
timer.startNew();

// ----- ARROW FUNCTIONS AS ARRAY CALLBACKS -----
const products = [
  { name: 'Keyboard', price: 79 },
  { name: 'Monitor', price: 299 },
  { name: 'Mouse', price: 45 },
];

// map with an arrow function — clean, one-liner transformation
const productNames = products.map((product) => product.name);
console.log('Product names:', productNames); // ['Keyboard', 'Monitor', 'Mouse']

// filter — only items under $100
const affordableProducts = products.filter((product) => product.price < 100);
console.log('Under $100:', affordableProducts.map((p) => p.name)); // ['Keyboard', 'Mouse']

// ----- OBJECT DESTRUCTURING -----
const orderDetails = {
  orderId: 'ORD-8821',
  customer: 'Bob Martinez',
  total: 124.5,
  status: 'shipped',
};

// Extract only what you need — notice the rename: status -> orderStatus
const { orderId, customer, status: orderStatus } = orderDetails;
console.log(`Order ${orderId} for ${customer} is ${orderStatus}`);
// Order ORD-8821 for Bob Martinez is shipped

// ----- ARRAY DESTRUCTURING -----
const [firstPlace, secondPlace, , fourthPlace] = ['Alice', 'Bob', 'Carol', 'Dave'];
console.log('Winner:', firstPlace);   // Alice
console.log('Runner-up:', secondPlace); // Bob
console.log('4th place:', fourthPlace); // Dave (skipped Carol with the empty comma)

// ----- DESTRUCTURING IN FUNCTION PARAMETERS (very common in React) -----
function renderUserCard({ name, role = 'viewer', avatarUrl = '/default-avatar.png' }) {
  // Default values in destructuring mean we never get undefined for missing fields
  console.log(`Rendering card for ${name} (${role}) — avatar: ${avatarUrl}`);
}

renderUserCard({ name: 'Alice', role: 'admin' });
// Rendering card for Alice (admin) — avatar: /default-avatar.png
renderUserCard({ name: 'Charlie' });
// Rendering card for Charlie (viewer) — avatar: /default-avatar.png
▶ Output
Old way — this.message: undefined
Arrow way — this.message: Time is up!
Product names: [ 'Keyboard', 'Monitor', 'Mouse' ]
Under $100: [ 'Keyboard', 'Mouse' ]
Order ORD-8821 for Bob Martinez is shipped
Winner: Alice
Runner-up: Bob
4th place: Dave
Rendering card for Alice (admin) — avatar: /default-avatar.png
Rendering card for Charlie (viewer) — avatar: /default-avatar.png
⚠️
Pro Tip: Don't Use Arrow Functions as Object MethodsBecause arrow functions don't bind their own `this`, using them as methods directly on an object will cause `this` to point to the outer scope (often `undefined` in modules, or the global object in scripts) instead of the object itself. Always use regular function syntax for object methods and class methods. Arrow functions shine as callbacks and helper functions — not as the primary method definition.

Spread, Rest, and Template Literals — Clean Data Handling

The spread operator (...) lets you 'unpack' an array or object into individual pieces. Think of it like opening a box and laying everything out on a table. Its sibling, the rest parameter, does the opposite — it gathers a variable number of arguments into an array. Same syntax, opposite directions, and understanding both together prevents a lot of confusion.

These aren't just convenience features. Spread is the backbone of immutable data patterns in React and Redux — instead of mutating an existing object, you spread it into a new one with your changes. That one pattern is responsible for making component state predictable across thousands of React apps.

Template literals (backtick strings) replace string concatenation entirely. They support multiline strings without escape characters, and embedded expressions with ${} that can hold any JavaScript expression — not just variables.

spreadRestTemplates.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// ----- SPREAD WITH ARRAYS -----
const northernCities = ['Oslo', 'Stockholm', 'Helsinki'];
const southernCities = ['Rome', 'Athens', 'Madrid'];

// Combine two arrays without mutation
const allCities = [...northernCities, 'Paris', ...southernCities];
console.log('All cities:', allCities);
// ['Oslo', 'Stockholm', 'Helsinki', 'Paris', 'Rome', 'Athens', 'Madrid']

// Clone an array (not a reference — a new array)
const citiesCopy = [...northernCities];
citiesCopy.push('Reykjavik');
console.log('Original unchanged:', northernCities); // Oslo, Stockholm, Helsinki
console.log('Clone has new city:', citiesCopy);      // Oslo, Stockholm, Helsinki, Reykjavik

// ----- SPREAD WITH OBJECTS (the React state update pattern) -----
const currentUserSettings = {
  theme: 'dark',
  language: 'en',
  notificationsEnabled: true,
};

// Create a new settings object with just the theme changed
// The spread copies all existing keys, then the last key 'wins' for overrides
const updatedSettings = { ...currentUserSettings, theme: 'light' };
console.log('Original settings:', currentUserSettings.theme); // dark — untouched
console.log('Updated settings:', updatedSettings.theme);      // light

// ----- REST PARAMETERS -----
function calculateShippingCost(baseRate, ...itemWeights) {
  // `itemWeights` collects all arguments after the first into a real array
  const totalWeight = itemWeights.reduce((sum, weight) => sum + weight, 0);
  const shippingCost = baseRate + totalWeight * 0.5;
  console.log(`Items: ${itemWeights.length}, Total weight: ${totalWeight}kg, Cost: $${shippingCost.toFixed(2)}`);
}

calculateShippingCost(5, 1.2, 0.8, 3.5); // 3 items
// Items: 3, Total weight: 5.5kg, Cost: $7.75
calculateShippingCost(5, 0.5);            // 1 item
// Items: 1, Total weight: 0.5kg, Cost: $5.25

// ----- TEMPLATE LITERALS -----
const orderSummary = {
  id: 'ORD-4492',
  itemCount: 3,
  total: 87.49,
  deliveryDate: 'Thursday',
};

// Multiline template literal — no more \n escape sequences
const confirmationEmail = `
Hi there,

Your order ${orderSummary.id} has been confirmed.
You ordered ${orderSummary.itemCount} items for a total of $${orderSummary.total.toFixed(2)}.
Expected delivery: ${orderSummary.deliveryDate}.

Thank you for shopping with us!
`.trim();

console.log(confirmationEmail);
// Full email block with real line breaks — no string concatenation needed
▶ Output
All cities: [ 'Oslo', 'Stockholm', 'Helsinki', 'Paris', 'Rome', 'Athens', 'Madrid' ]
Original unchanged: [ 'Oslo', 'Stockholm', 'Helsinki' ]
Clone has new city: [ 'Oslo', 'Stockholm', 'Helsinki', 'Reykjavik' ]
Original settings: dark
Updated settings: light
Items: 3, Total weight: 5.5kg, Cost: $7.75
Items: 1, Total weight: 0.5kg, Cost: $5.25
Hi there,

Your order ORD-4492 has been confirmed.
You ordered 3 items for a total of $87.49.
Expected delivery: Thursday.

Thank you for shopping with us!
🔥
Interview Gold: Spread Creates Shallow CopiesSpreading an object or array only copies one level deep. If your object has a nested object as a value, the spread gives you a new outer object but both the original and the copy point to the same nested object. Changing that nested object affects both. For deep cloning, use structuredClone() (modern) or JSON.parse(JSON.stringify(obj)) (older workaround, with caveats around dates and functions).

Promises and async/await — Taming Asynchronous Code

Almost everything interesting in JavaScript is asynchronous — fetching data from an API, reading a file, waiting for a user input. Before Promises, the only pattern was nested callbacks. Each async step required a new callback function, and if you had five steps, you had five levels of indentation. It was visually and logically disorienting, which is why 'callback hell' became a well-known term with its own dedicated website.

A Promise is a placeholder for a value that doesn't exist yet. It's in one of three states: pending (waiting), fulfilled (got the value), or rejected (something went wrong). You chain .then() to handle success and .catch() to handle errors.

async/await (ES2017) is syntax sugar built on top of Promises. It lets you write asynchronous code that reads like synchronous code — top to bottom, no chaining. Under the hood, it's still Promises. The two approaches aren't opposites; they're the same mechanism with different ergonomics. You should understand both because you'll see both in real codebases.

asyncPatterns.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// ----- SIMULATING AN API CALL WITH A PROMISE -----
// In real code this would be fetch() — here we simulate network delay
function fetchUserById(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const mockDatabase = {
        101: { name: 'Alice Chen', email: 'alice@example.com', plan: 'pro' },
        102: { name: 'Bob Singh', email: 'bob@example.com', plan: 'free' },
      };
      const user = mockDatabase[userId];
      if (user) {
        resolve(user); // success path
      } else {
        reject(new Error(`No user found with ID ${userId}`)); // error path
      }
    }, 300); // simulate 300ms network delay
  });
}

// ----- USING PROMISE CHAINING (.then / .catch) -----
fetchUserById(101)
  .then((user) => {
    console.log('Promise chain — User found:', user.name);
    return user.plan; // return value flows into the next .then
  })
  .then((plan) => {
    console.log('User plan:', plan); // 'pro'
  })
  .catch((error) => {
    console.error('Promise error:', error.message);
  });

// ----- SAME LOGIC WITH async/await — FAR MORE READABLE -----
async function loadUserProfile(userId) {
  try {
    const user = await fetchUserById(userId); // pauses here until Promise resolves
    console.log('async/await — User found:', user.name);
    console.log('Email:', user.email);
    return user; // async functions always return a Promise
  } catch (error) {
    // Errors from the awaited Promise land here — equivalent to .catch()
    console.error('async/await error:', error.message);
  }
}

loadUserProfile(102); // valid user
loadUserProfile(999); // user that doesn't exist — triggers the catch

// ----- RUNNING MULTIPLE REQUESTS IN PARALLEL -----
async function loadDashboardData() {
  console.log('\nLoading dashboard data in parallel...');

  // Promise.all runs both requests AT THE SAME TIME and waits for both to finish
  // This is faster than awaiting them one after the other
  const [user101, user102] = await Promise.all([
    fetchUserById(101),
    fetchUserById(102),
  ]);

  console.log('Dashboard loaded:');
  console.log(`- ${user101.name} (${user101.plan})`);
  console.log(`- ${user102.name} (${user102.plan})`);
}

loadDashboardData();
▶ Output
Promise chain — User found: Alice Chen
User plan: pro
async/await — User found: Bob Singh
Email: bob@example.com
async/await error: No user found with ID 999

Loading dashboard data in parallel...
Dashboard loaded:
- Alice Chen (pro)
- Bob Singh (free)
⚠️
Watch Out: await in a Loop Is Usually a Performance BugWriting `for (const id of userIds) { const user = await fetchUser(id); }` sends each request one at a time — each waits for the previous to finish. For a list of 10 users, that's 10x slower than it needs to be. Use `Promise.all(userIds.map(id => fetchUser(id)))` to fire all requests simultaneously. The exception is when each request genuinely depends on the previous result — then sequential await is correct.
FeatureOld Approach (ES5)ES6+ Approach
Variable declarationvar (function-scoped, hoisted)let / const (block-scoped, predictable)
Function syntaxfunction keyword everywhereArrow functions for callbacks; regular functions for methods
String building"Hello " + name + ", you have " + count + " messages"`Hello ${name}, you have ${count} messages`
Extracting object valuesvar name = user.name; var age = user.age;const { name, age } = user;
Combining arrays/objectsArray.prototype.concat(), Object.assign()Spread operator: [...arr], {...obj}
Async codeNested callbacks (callback hell)Promises + async/await (reads top-to-bottom)
Variable number of argsarguments object (array-like, not real array)Rest parameters: ...args (a real array)
this in callbacksvar self = this; or .bind(this)Arrow functions inherit this lexically

🎯 Key Takeaways

  • Use const by default and let only when reassignment is genuinely needed — var should never appear in code written after 2015.
  • Arrow functions solve the this binding problem in callbacks, but they are not a universal replacement for function — never use them as object/class methods.
  • Spread (...) creates shallow copies only — nested objects are still shared references, which will surprise you the first time you mutate a 'copy' and see the original change too.
  • async/await is Promises with better syntax — you must still handle rejections with try/catch, and sequential await inside a loop is a performance trap that Promise.all solves.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using an arrow function as an object method and wondering why this is undefined — Arrow functions don't bind their own this, so const obj = { greet: () => console.log(this.name) } will log undefined because this refers to the outer scope, not obj. Fix: Use a regular function for object methods: greet: function() { console.log(this.name); } or the shorthand greet() { console.log(this.name); }.
  • Mistake 2: Assuming const makes objects immutable — const config = { debug: false }; config.debug = true; works fine and changes the property. const only prevents you from reassigning the variable to a completely new object. Fix: If you need a frozen object, use const config = Object.freeze({ debug: false }); — attempting to change a frozen object silently fails in sloppy mode and throws in strict mode.
  • Mistake 3: Forgetting to handle Promise rejections with async/await — An unawaited Promise rejection that has no .catch() or try/catch wrapper causes an UnhandledPromiseRejection warning in Node.js (and will crash the process in newer versions). Fix: Always wrap await calls in a try/catch, or add .catch() to every Promise chain. You can also add a global handler with process.on('unhandledRejection', handler) as a safety net, but it's not a replacement for proper error handling at the call site.

Interview Questions on This Topic

  • QWhat is the difference between `let`, `const`, and `var`? Can you describe a bug that `var` can cause that `let` would prevent?
  • QExplain why you can't use an arrow function as a constructor (i.e., with `new`), and give an example of when you should NOT use an arrow function.
  • QWhat is the difference between `Promise.all()` and `Promise.allSettled()`? If one of three parallel API requests fails, how does each one behave — and when would you prefer one over the other?

Frequently Asked Questions

Do I need to know ES6 for React or Node.js development?

Yes — ES6+ isn't optional in modern JavaScript development. React's component model relies heavily on destructuring, spread, arrow functions, and modules. Node.js uses async/await for nearly all I/O operations. Trying to read or write React or Node code without ES6+ knowledge is like trying to read a book with half the vocabulary missing.

What is the difference between `==` and `===` in JavaScript, and is that an ES6 thing?

=== (strict equality) checks both value AND type with no coercion — '5' === 5 is false. == (loose equality) coerces types first — '5' == 5 is true, which causes subtle bugs. Strict equality existed before ES6, but the ES6+ era solidified the community norm of always using ===. You should use === in all new code.

Is there a performance difference between arrow functions and regular functions?

In practice, no — modern JavaScript engines (V8, SpiderMonkey) optimize both to equivalent machine code. Choose between them based on the this binding behavior and readability, not performance. The only micro-performance consideration is that arrow functions cannot be used as constructors, so the engine skips allocating a prototype — but this is irrelevant unless you're benchmarking millions of instantiations per second.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPrototypes and Inheritance in JSNext →Destructuring in JavaScript
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged