ES6+ Features Explained — The Why, When, and Real-World How
- 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.
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.
// ----- 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
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.
// ----- 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
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.
// ----- 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)
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.| 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
Interview Questions on This Topic
- QWhat is the difference between
let,const, andvar? Can you describe a bug thatvarcan cause thatletwould 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()andPromise.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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.