ES6+ JavaScript Core
Everything modern JavaScript gives you end to end: block scope with let/const, arrow functions and lexical this, template literals, destructuring, spread/rest, default params, ES Modules, promises and async/await, the map/filter/reduce toolkit, optional chaining and nullish coalescing, and the immutable-update patterns React lives on — beginner to advanced in one page.
What ES6+ is and why it matters. 'ES6' is ECMAScript 2015 — the release that modernised JavaScript — and 'ES6+' loosely means everything since (ES2016 through today). It is the dialect React, Node, and every modern tool assume. You do not need to memorise version numbers; you need to internalise a handful of features that changed how we declare variables, write functions, move data around, and handle asynchrony. Master these and every React tutorial suddenly reads like plain English.
let, const, var and scope. var is function-scoped and hoisted — it is visible (as undefined) before its own line runs, which causes subtle bugs. let and const are block-scoped: they exist only inside the nearest { } and live in a temporal dead zone (referencing them before declaration throws) which catches mistakes early. Rule: const by default, let only when you must reassign, var never. Crucially, const freezes the binding, not the value — a const array or object can still be mutated in place; you just can't point the name at something else.
Arrow functions and lexical this. Arrow functions (const add = (a, b) => a + b) are shorter, and — the real reason they exist — they do not bind their own this. They capture this from the surrounding (lexical) scope. In old code you wrote const self = this or .bind(this) to use this inside a callback; arrows make that noise disappear, which is why event handlers and array callbacks are almost always arrows. Trade-off: because they have no own this, do not use an arrow as an object method that needs this, or as a constructor.
Template literals. Backtick strings let you interpolate expressions with the dollar-brace syntax and write multi-line strings without escapes. Instead of 'Hello ' + name + '!' you write a backtick string with the name interpolated inline. They also power tagged templates (the mechanism behind styled-components) where a function receives the string parts and the interpolated values separately.
Destructuring. Pull fields out of objects and arrays into local variables in one line: const { name, role } = user and const [first, second] = list. You can rename (const { id: userId } = user), supply defaults (const { page = 1 } = query), and destructure nested shapes and function parameters. React uses this everywhere — const [count, setCount] = useState(0) is array destructuring, and function Card({ title, children }) is parameter destructuring.
Spread and rest. They look identical (...) but do opposite things. Spread expands an iterable into its elements — copy and merge objects ({ ...a, ...b }), clone and extend arrays ([...list, item]), or pass an array as arguments (Math.max(...nums)). Rest collects the leftovers into one variable — function log(first, ...others) gathers remaining arguments into others, and const [head, ...tail] = arr gathers the rest of the array. Same symbol, position tells you which.
Default parameters. Give a parameter a fallback used only when the argument is undefined: function greet(name = 'friend'). Defaults are evaluated at call time and can reference earlier parameters. They replaced the old name = name || 'friend' idiom, which mis-fired on falsy-but-valid values like 0 or ''.
ES Modules (import/export). Modern JavaScript splits code into files that explicitly declare what they share. A file can have many named exports (export function sum() {}) imported by name with braces, and one default export imported without braces under any name. Imports are hoisted and statically analysable, which is what lets bundlers tree-shake unused code. This is the module system React, Next.js, and every build tool use — CommonJS require is the older Node style you'll still meet.
Promises. A Promise is an object representing a value that will exist later — it is pending, then either fulfilled (with a value) or rejected (with an error). You react with .then(onFulfilled) and .catch(onError), and each .then returns a new promise so they chain instead of nesting into callback hell. Promise.all([...]) runs several in parallel and resolves when all settle; Promise.race, Promise.allSettled, and Promise.any cover the other combinations.
async/await. async/await is syntactic sugar over promises that lets asynchronous code read top-to-bottom like synchronous code. await pauses an async function until a promise settles and unwraps its value; errors surface through ordinary try/catch instead of .catch chains. An async function always returns a promise, so callers still await it. This is the default style for data fetching in modern apps.
The array toolkit — map, filter, reduce, find. These non-mutating methods are your render and data-shaping workhorses and map almost one-to-one onto Java Streams. map transforms each element into a new array of the same length (this is how React renders lists). filter keeps elements that pass a test. reduce folds an array into a single accumulated value (sum, group, index). find returns the first match (or undefined); some/every return booleans. They chain — filter(...).map(...).reduce(...) — to express pipelines declaratively, and none of them mutate the source array.
Optional chaining and nullish coalescing. ?. short-circuits: user?.profile?.avatar returns undefined instead of throwing if any link is null/undefined — no more user && user.profile && ... ladders. ?? supplies a fallback only when the left side is null or undefined, unlike || which also fires on 0, '', and false. Combine them: const page = query?.page ?? 1 safely reads a possibly-missing value with a sane default. ?.() and ?.[] extend the idea to calls and index access.
Immutable update patterns (why React needs them). React re-renders by comparing references, not deep contents. If you push into an array or assign to obj.key, the reference is unchanged and React sees nothing. Instead you create a new reference: { ...obj, key: val } to change a field, [...arr, item] to add, arr.filter(x => x.id !== id) to remove, and arr.map(x => x.id === id ? { ...x, done: true } : x) to update one item. For nested state you spread at each level you change. This 'copy-on-write' discipline is the single most important habit for correct React state.
The mental model (memorise this). Declare with const/let (block-scoped, const freezes the binding not the value); reach for arrows to keep this lexical; destructure to unpack and spread/rest to copy and gather; treat data as immutable — always produce a new reference ({...} / [...] / map/filter) so React notices; and handle asynchrony with async/await over promises, guarding missing data with ?. and ??.
The array toolkit is the Java Stream API almost verbatim: map/filter/reduce/find/some/every line up with .map/.filter/.reduce/.findFirst/.anyMatch/.allMatch. Destructuring is like reading a Java record's components into locals. Spreading into a new object is BeanUtils.copyProperties() to a fresh instance then overriding a field — you never mutate the original. ES Modules are Java packages with explicit exports/imports (a default export is like the primary public class). Promises are CompletableFuture, and async/await is CompletableFuture.thenApply/thenCompose written in a flat, blocking-looking style — an async method 'returning a value' really returns a CompletableFuture the caller composes on. Optional chaining (?.) and ?? together are Java's Optional.map(...).orElse(default).
- const by default, let to reassign, never var. let/const are block-scoped with a temporal dead zone; var is function-scoped and hoisted.
- const freezes the binding, not the value — a const array or object can still be mutated in place.
- Arrow functions have no own this — they capture it lexically, which is why they are the default for callbacks and handlers; don't use them as methods needing this or as constructors.
- Spread (...) expands/copies; rest (...) collects leftovers — same symbol, position decides which.
- map returns a same-length transformed array (React list rendering), filter narrows, reduce folds to one value, find returns the first match — and none mutate the source.
- async functions always return a promise; await unwraps it and errors go through try/catch. It is sugar over promises, not a replacement.
- ?. short-circuits on null/undefined; ?? supplies a fallback only for null/undefined (unlike || which also triggers on 0, '' and false).
- React compares references — mutation (push, obj.key = x) is invisible to it. Always create a new reference with spread, map, or filter.
- Default params fire only on undefined, fixing the old `x || fallback` bug that mis-handled 0 and empty string.
- ES Modules: many named exports (braces on import) + one default export (no braces); static imports enable tree-shaking.
Worked Code
// const by default, let to reassign, never var
const name = 'Ada'; // immutable binding
let count = 0; // reassignable
count += 1;
// const freezes the BINDING, not the VALUE
const ids = [1, 2, 3];
ids.push(4); // OK — mutating the array is allowed
// ids = []; // TypeError — reassigning the binding is not
// Block scope + temporal dead zone
{
let scoped = 'only here';
console.log(scoped);
}
// console.log(scoped); // ReferenceError — not visible outside the block
// Arrow functions capture 'this' lexically (great for callbacks)
const nums = [1, 2, 3];
const doubled = nums.map((n) => n * 2); // concise body: implicit return
// Template literals: interpolation + multi-line, no concatenation
const greeting = `Hi ${name}, you have ${count} messages`;
const html = `<li class="row">${name}</li>`;const user = { id: 7, name: 'Ada', role: 'user' };
// Object destructuring — with rename and default
const { name, role, page = 1 } = user; // page falls back to 1
const { id: userId } = user; // rename id -> userId
// Array destructuring — with rest
const [first, ...others] = [10, 20, 30]; // first=10, others=[20,30]
// Spread: copy + merge objects, clone + extend arrays
const merged = { ...user, role: 'admin' }; // new object, role overridden
const extended = [first, ...others, 40]; // new array
// Spread as arguments
console.log(Math.max(...[4, 9, 2])); // 9
// Rest: collect variadic arguments
function logAll(label, ...values) { // values is a real array
console.log(label, values.length);
}
// Default parameters (fire only on undefined)
function greet(who = 'friend') { return `Hello, ${who}!`; }
greet(); // "Hello, friend!"
greet(0); // "Hello, 0!" — 0 is a valid argument, default not usedconst expenses = [
{ id: 1, amount: 120, category: 'meals', approved: true },
{ id: 2, amount: 800, category: 'travel', approved: false },
{ id: 3, amount: 250, category: 'meals', approved: true },
];
// map — transform each item (this is how React renders lists)
const amounts = expenses.map((e) => e.amount); // [120, 800, 250]
// filter — keep matching items
const approved = expenses.filter((e) => e.approved); // 2 items
// reduce — fold into a single value (sum, group, index...)
const total = expenses.reduce((sum, e) => sum + e.amount, 0); // 1170
// find — first match or undefined; some/every — booleans
const firstTravel = expenses.find((e) => e.category === 'travel');
const hasTravel = expenses.some((e) => e.category === 'travel'); // true
const allApproved = expenses.every((e) => e.approved); // false
// Chaining — a declarative pipeline, source never mutated
const approvedMealsTotal = expenses
.filter((e) => e.approved && e.category === 'meals')
.reduce((sum, e) => sum + e.amount, 0); // 370
// reduce to build a lookup index (id -> item)
const byId = expenses.reduce((acc, e) => ({ ...acc, [e.id]: e }), {});const state = { user: { name: 'Ada', role: 'user' }, tags: ['a', 'b'] };
// WRONG — mutation: same reference, React sees no change
// state.user.role = 'admin';
// state.tags.push('c');
// RIGHT — new references at every level you change
const updateRole = { ...state, user: { ...state.user, role: 'admin' } };
const addTag = { ...state, tags: [...state.tags, 'c'] };
const removeTag = { ...state, tags: state.tags.filter((t) => t !== 'a') };
// Update ONE item in a list by id
const todos = [{ id: 1, done: false }, { id: 2, done: false }];
const toggled = todos.map((t) => (t.id === 1 ? { ...t, done: true } : t));
// Optional chaining + nullish coalescing for safe reads
const avatar = state.user?.profile?.avatar ?? '/default.png';
const label = state.count ?? 0; // 0 stays 0 (|| would wrongly override)// --- math.js ---
export function sum(a, b) { return a + b; } // named export
export default function multiply(a, b) { // default export
return a * b;
}
// --- app.js ---
import multiply, { sum } from './math.js'; // default + named
// async/await reads top-to-bottom; errors via try/catch
async function loadExpenses() {
try {
const res = await fetch('/api/expenses');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json(); // an async fn returns a Promise
} catch (err) {
console.error('Failed to load:', err.message);
throw err; // let the caller decide
}
}
// Run independent requests in parallel with Promise.all
async function loadDashboard() {
const [user, stats] = await Promise.all([
fetch('/api/user').then((r) => r.json()),
fetch('/api/stats').then((r) => r.json()),
]);
return { user, stats };
}▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
var is function-scoped and hoisted (visible as undefined before its line), which causes subtle bugs. let and const are block-scoped and live in a temporal dead zone until declared, so using them early throws. const cannot be reassigned; let can. Note const only prevents reassignment of the binding — the underlying object or array can still be mutated. Best practice: const by default, let when you must reassign, never var.
- 1const by default, let to reassign, never var; const blocks reassignment but not mutation.
- 2let/const are block-scoped with a temporal dead zone; var is function-scoped and hoisted.
- 3Arrow functions capture this lexically — default for callbacks; avoid as methods/constructors.
- 4Spread expands/copies; rest collects. map/filter/reduce/find are your non-mutating render toolkit and mirror Java Streams.
- 5For React state always create a new reference: { ...obj }, [...arr], map, filter — never push or mutate in place.
- 6Default params fire only on undefined (fixes the old `x || fallback` bug on 0 and '').
- 7async functions always return a promise; await + try/catch replaces .then/.catch chains.
- 8?. short-circuits on null/undefined; ?? falls back only on null/undefined (|| also fires on 0/''/false).
- 9ES Modules: many named exports (braces) + one default (no braces); static imports enable tree-shaking.
- 10Template literals interpolate expressions and span multiple lines — stop concatenating strings.