ES6+ Features Explained — The Why, When, and Real-World How
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.
// ----- 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
let prevents the accidental leak — discount is not visible here
Total with tax: 53.99
Updated role: editor
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.
// ----- 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
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
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.
// ----- 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
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!
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.
// ----- 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();
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)
| Feature | Old Approach (ES5) | ES6+ Approach |
|---|---|---|
| Variable declaration | var (function-scoped, hoisted) | let / const (block-scoped, predictable) |
| Function syntax | function keyword everywhere | Arrow functions for callbacks; regular functions for methods |
| String building | "Hello " + name + ", you have " + count + " messages" | `Hello ${name}, you have ${count} messages` |
| Extracting object values | var name = user.name; var age = user.age; | const { name, age } = user; |
| Combining arrays/objects | Array.prototype.concat(), Object.assign() | Spread operator: [...arr], {...obj} |
| Async code | Nested callbacks (callback hell) | Promises + async/await (reads top-to-bottom) |
| Variable number of args | arguments object (array-like, not real array) | Rest parameters: ...args (a real array) |
| this in callbacks | var self = this; or .bind(this) | Arrow functions inherit this lexically |
🎯 Key Takeaways
- Use
constby default andletonly when reassignment is genuinely needed —varshould never appear in code written after 2015. - Arrow functions solve the
thisbinding problem in callbacks, but they are not a universal replacement forfunction— 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 sequentialawaitinside a loop is a performance trap thatPromise.allsolves.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using an arrow function as an object method and wondering why
thisis undefined — Arrow functions don't bind their ownthis, soconst obj = { greet: () => console.log(this.name) }will logundefinedbecausethisrefers to the outer scope, notobj. Fix: Use a regular function for object methods:greet: function() { console.log(this.name); }or the shorthandgreet() { console.log(this.name); }. - ✕Mistake 2: Assuming const makes objects immutable —
const config = { debug: false }; config.debug = true;works fine and changes the property.constonly prevents you from reassigning the variable to a completely new object. Fix: If you need a frozen object, useconst 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()ortry/catchwrapper causes anUnhandledPromiseRejectionwarning in Node.js (and will crash the process in newer versions). Fix: Always wrapawaitcalls in atry/catch, or add.catch()to every Promise chain. You can also add a global handler withprocess.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.
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.