ES6+ Features Explained — The Why, When, and Real-World How
ES6+ JavaScript features demystified — learn arrow functions, destructuring, async/await, and more with real-world patterns and interview-ready explanations.
- ES6+ is a set of modern JavaScript syntax and features that reduce boilerplate and eliminate common bugs.
- Key features: arrow functions, let/const, destructuring, spread/rest, template literals, Promises, async/await, modules.
- Performance: arrow functions don't bind
this— saves memory per callback, but no performance difference in execution. - Production insight: Forgetting to handle Promise rejections crashes Node.js processes in newer versions.
- Biggest mistake: Using arrow functions as object methods breaks
this— always use regular function syntax for methods.
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.
Object.freeze() — but know that freeze is only one level deep.Object.freeze() only when you need deep immutabilityArrow 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.
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.this.setState throws 'undefined' because this is the window or undefined. The fix: always use class method syntax or bind in constructor.this is undefined in a method, check the definition — arrow function? replace with function() {} or method shorthand.this lexically — great for callbacks, deadly for methodsSpread, 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.
structuredClone() for deep config copies or manually clone nested properties.arguments with a real arrayThe for...of Loop — Iterating Over Iterables
Before ES6, iterating over arrays required a for loop with an index, or the forEach method (which doesn't support break/continue/return). For objects, you'd use for...in, which iterates over enumerable property names including inherited ones, often causing bugs.
The for...of loop (ES6) solves this by working directly with iterables — arrays, strings, Maps, Sets, NodeLists, and any object implementing the iterable protocol. It gives you values, not indices, and supports break, continue, and return. It's the cleanest way to loop through built-in data structures.
Use for...of when you need the values of an array or any iterable. Use for...in only when you need object keys (and you're sure about property enumeration order).
forEach does not support break or continue — you'd need to throw an exception or use a return (which only exits the callback). for...of supports all loop control statements and is often more performant for large arrays because it avoids the overhead of a callback per iteration.for...in on arrays in production — it iterates over enumerable properties (including array indices as strings) and can pick up inherited enumerable properties from prototypes. Always use for...of for arrays. A common bug: iterating over a NodeList with for...in returns unexpected properties because the NodeList inherits from Object.prototype.for...of for iterables, for...in only for plain objects when you need keys.Map and Set — New Data Structures for Modern JavaScript
ES6 introduced two new built-in data structures: Map (key-value pairs where keys can be any type) and Set (unique values of any type). They fill gaps that plain objects and arrays left open.
Map vs Object: A Map preserves insertion order, performs better with frequent additions/removals, and accepts any value as a key (including objects, functions, NaN). Objects convert keys to strings (e.g., { 'true': 1 }), while Maps keep the original type.
Set vs Array: A Set automatically enforces uniqueness — no duplicate values. It provides .has(value) in O(1) time, which is much faster than Array.includes() (O(n)). Use Set when you need to track unique items and test membership.
WeakMap and WeakSet hold 'weak' references — they don't prevent garbage collection of keys. Use them when you need to associate data with objects without preventing their cleanup (e.g., caching DOM elements in a single-page app).
size property (objects don't have a built-in size).for...in included prototype properties. Switching to Map fixed both issues and gave predictable performance.New String and Array Built-in Methods Reference Table
ES6+ added many practical methods to String and Array prototypes. Here's a quick reference for the most commonly used ones:
| Method | Category | Description | Example |
|---|---|---|---|
String.prototype.includes() | String | Returns true if string contains substring | 'Hello'.includes('ell') → true |
String.prototype.startsWith() | String | Checks if string starts with substring | 'file.js'.startsWith('file') → true |
String.prototype.endsWith() | String | Checks if string ends with substring | 'file.js'.endsWith('.js') → true |
String.prototype.repeat() | String | Returns new string repeated N times | 'ha'.repeat(3) → 'hahaha' |
Array.prototype.find() | Array | Returns first element that passes a test | [5,12,8,130].find(x => x > 10) → 12 |
Array.prototype.findIndex() | Array | Returns index of first passing element | [5,12,8,130].findIndex(x => x > 10) → 1 |
Array.prototype.fill() | Array | Fills elements with a static value | [1,2,3].fill(0, 0, 2) → [0, 0, 3] |
Array.prototype.includes() | Array | Checks if array contains a value | [1,2,3].includes(2) → true |
Array.prototype.keys() | Array | Returns iterator of indices | [...['a','b'].keys()] → [0,1] |
Array.prototype.values() | Array | Returns iterator of values | [...['a','b'].values()] → ['a','b'] |
Array.prototype.entries() | Array | Returns iterator of [index, value] pairs | [...['x','y'].entries()] → [[0,'x'],[1,'y']] |
Array.prototype.flat() | ES2019 | Flattens nested arrays to specified depth | [1,[2,[3]]].flat(2) → [1,2,3] |
Array.prototype.flatMap() | ES2019 | Maps then flattens result by one level | [1,2].flatMap(x => [x, x*10]) → [1,10,2,20] |
Use these methods instead of manual loops for cleaner, more declarative code. They are widely supported in modern environments and polyfills exist for legacy browsers.
flat and flatMap, support starts from Chrome 69, Firefox 62, Safari 12, Node 11. Use polyfills (like core-js) if targeting older environments.Array.includes() instead of indexOf() !== -1 improves readability and eliminates a common source of boolean confusion. Similarly, findIndex() is clearer than manually looping to find an index. These methods are well-optimized in modern V8; no performance penalty..includes(), .find(), and .findIndex() over manual loops or indexOf for clarity.ES5 vs ES6 Comparison Table for Each Major Feature
Here's a side-by-side reference of how the most important features changed from ES5 to ES6+. Use this table as a quick reminder when refactoring or reviewing code.
| Feature | ES5 Approach | ES6+ Approach |
|---|---|---|
| Variable declaration | var (function scope, hoisting) | let / const (block scope, TDZ) |
| Function syntax | everywhere | Arrow functions () => {} for callbacks; regular function for methods |
this in callbacks | var self = this; or .bind(this) | Arrow functions inherit this lexically |
| String concatenation | 'Hello ' + name + '!'; | Template literals: ` Hello ${name}! ` |
| Extracting values | var name = obj.name; var age = obj.age; | Destructuring: const { name, age } = obj; |
| Copying/merging objects | Object.assign({}, obj) | Spread: { ...obj } |
| Copying/merging arrays | Array.prototype.concat() or | Spread: [...arr] |
| Variable number of args | arguments object (array-like) | Rest parameters: ...args (real array) |
| Async code | Nested callbacks (callback hell) | Promises + async/await (linear flow) |
| Module system | <script> tags, IIFEs, globals | import / export (static, tree-shakable) |
| Iterating arrays | for (var i=0; i<arr.length; i++) or arr.forEach() | for...of loop (values, break/continue) |
| Data structures | Plain objects and arrays | Map, Set, WeakMap, WeakSet |
| String methods | indexOf() for substring check | .includes(), .startsWith(), .endsWith() |
| Array methods | Manual loops or indexOf | .find(), .findIndex(), .includes(), .flat(), .flatMap() |
This table doesn't cover every change, but it captures the most impactful shifts that you'll encounter daily.
var with const/let, convert callback nesting to Promises/async/await, switch string concatenation to template literals, and introduce destructuring and spread. The table gives you a clear 'before and after' for each change.Arrow Function in Event Listener Crashes Live User Profile
this.submit being undefined.button.addEventListener('click', () => this.submitForm()) inside a class component.this; they inherit from the enclosing scope. Inside a class method, the enclosing scope is the class instance, but when the arrow function is passed as a callback to addEventListener, the this inside the arrow function still refers to the class instance (correct). However, in this case, the arrow function was defined in a constructor, but the this inside the constructor's arrow callback actually correctly refers to the instance. The bug was different: the developer used an arrow function as an object method directly: const handler = { click: () => this.submitForm() } and then passed handler.click. The arrow function's this was the global object (or undefined in strict mode) because it was not called on an object. The root cause: arrow functions are not suitable for object methods.click: function() { this.submitForm(); } or use method shorthand click() { this.submitForm(); }. The event listener then worked because this was correctly bound to the object.- Never use arrow functions as object methods or when you need dynamic
thisbinding (e.g., event listeners on DOM elements where you wantthisto be the element). - Use arrow functions for callbacks where you want to capture the surrounding
this(e.g., in class methods passed to setTimeout). - If you see 'undefined' where an object method should be, suspect arrow function misuse first.
this — if used as a method on an object literal, this is the outer scope. Replace with regular function or method shorthand..catch() to all Promise chains or wrap await calls in try/catch. If using top-level await in Node, use process.on('unhandledRejection', handler) as fallback.const result = asyncFunction(), you get a Promise. You must await it or use .then().{ method: () => { ... } } with { method() { ... } } or { method: function() { ... } }Key takeaways
const by default and let only when reassignment is genuinely neededvar should never appear in code written after 2015.this binding problem in callbacks, but they are not a universal replacement for function...) creates shallow copies onlytry/catch, and sequential await inside a loop is a performance trap that Promise.all solves.import/export) give you real scoping and eliminate global namespace pollutionCommon mistakes to avoid
6 patternsUsing arrow function as object method
this is undefined inside the method. Method call returns Cannot read property '...' of undefined.{ method() { ... } } instead of { method: () => { ... } }.Assuming `const` makes objects immutable
Object.freeze() for shallow immutability, or Object.freeze() recursively for deep freeze. Use structuredClone() for deep copying.Forgetting `await` inside async function
await when calling an async function inside another async function. Or use .then() if not in async context.Using `await` in a loop sequentially when requests are independent
await with Promise.all(): const results = await Promise.all(tasks.map(task => fetch(task)))Confusing rest parameters with the `arguments` object
arguments in an arrow function throws ReferenceError because arrow functions don't have arguments. Or using arguments where rest parameters would be cleaner....args) when you need a variable number of arguments. Rest parameters are a real array. Add 'use strict' only if needed - modern modules are strict by default.Not understanding that spread creates shallow copies
structuredClone() (modern browsers/Node 17+) or JSON.parse(JSON.stringify(obj)) (with caveats). For one-level objects, spread is fine.Interview Questions on This Topic
What is the difference between `let`, `const`, and `var`? Can you describe a bug that `var` can cause that `let` would prevent?
var is function-scoped and hoisted, meaning it can be accessed before declaration (value is undefined). let and const are block-scoped and not initialized before declaration (Temporal Dead Zone). const additionally prevents reassignment of the binding, but not mutation of the value.
Example bug with var: In a for loop, if you create closures inside the loop, each closure references the same var variable (due to hoisting), so after the loop, all closures see the final value. With let, each iteration creates a new binding, so closures work correctly.
``javascript for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // prints 3,3,3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // prints 0,1,2
}
``Frequently Asked Questions
That's Advanced JS. Mark it forged?
6 min read · try the examples if you haven't