Introduction
When a junior developer joined my team, he had been coding for about eight months. He knew the syntax. He could write functions, conditionals, loops. He had done a couple of React tutorials. He was enthusiastic and genuinely wanted to improve.
Three weeks into the project, he was stuck on a bug where data wasn’t showing up in the UI after an API call. He had been looking at it for two hours. I sat down next to him and asked one question: “Where do you think the data is right now?”
He looked at me blankly. Not because he was bad at his job — he wasn’t. But because nobody had ever asked him to think about where data lives at a given moment in time. He knew how to write code. He didn’t yet have a mental model for what the code was doing.
That moment crystallised something I had been learning slowly across years of mentoring. The gap between a junior and a senior developer is rarely about syntax. It’s almost entirely about mental models — the internal representations of how things work that let you reason about code you haven’t written yet, debug problems you’ve never seen before, and make architectural decisions with confidence.
Teaching JavaScript well means building those mental models, in the right order, with the right constraints. This is how I do it.
The Principles I Start With
Before we get into specifics, I want to share the principles that shape how I mentor — because they inform every decision about sequence, project choice, and feedback style.
Understanding before usage. I don’t let developers use a concept in production code until they can explain what it does in plain language. Not the syntax — the behaviour. What actually happens when this runs? Why does it work this way?
Friction is the curriculum. The best learning happens when something doesn’t work and the developer has to figure out why. I don’t rush to explain. I ask questions. “What did you expect to happen?” “What actually happened?” “What’s different between the two?”
Small, complete projects over isolated exercises. Exercises that test a single concept in isolation are useful for drilling syntax. But intuition comes from building things that have multiple moving parts, where the concepts interact. A form with validation teaches more JavaScript than fifty console.log exercises.
Teach the why, not just the how. Every time I introduce a concept, I introduce the problem it solves first. Not “here is how closures work” but “here is a problem that you cannot solve cleanly without understanding closures. Try to solve it first. Now let me show you the tool that makes it elegant.”
Stage One: The Fundamentals That Actually Matter
Most JavaScript tutorials cover the same topics in the same order: variables, conditionals, loops, functions, arrays, objects. That order is fine for reference but terrible for building understanding. Here’s the order I actually use — and why.
Start with types and what JavaScript does with them
Before we touch variables or functions, I spend real time on how JavaScript represents values — not the typeof operator, but the underlying idea that a value has a nature and JavaScript makes silent decisions about that nature constantly.
// This is fine
console.log(1 + 2); // 3
// This is surprising if you haven't thought about it
console.log('1' + 2); // '12'
// This is alarming if you haven't thought about it
console.log(1 - '2'); // -1
// This is genuinely confusing without a mental model
console.log(true + true); // 2
console.log([] + []); // ''
console.log([] + {}); // '[object Object]'
I don’t make juniors memorise these. I show them the behaviour and ask them to articulate why JavaScript might have made these decisions. Then I introduce the concept of implicit coercion — the automatic type conversion JavaScript does when you use an operator on values of different types — and I explain that understanding it is more important than avoiding it, because you’ll encounter it in other people’s code forever.
The first practical lesson: use === instead of ==, and understand what problem that solves.
Variables and scope — but taught through bugs
I don’t teach variables by listing var, let, and const. I teach them by creating a bug with var and asking the developer to diagnose it.
// What does this print? Guess before running it.
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
Almost every junior developer expects to see 0, 1, 2. They see 3, 3, 3. I give them a few minutes to figure out why. Most of them can’t. That’s the right place to introduce variable scope, the difference between function scope and block scope, and why let exists.
Then the same exercise with let:
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
// 0, 1, 2
Now they understand let not as syntax, but as a solution to a real problem they just experienced. That understanding sticks.
The const conversation is a different one. I teach it with an object:
const user = { name: 'Harry', role: 'operator' };
user.role = 'admin'; // This works. Why?
user = { name: 'Harry', role: 'admin' }; // This throws. Why?
This is where I introduce the distinction between mutating a value and reassigning a variable — a concept that will matter enormously when we get to React state and Angular change detection.
Functions — all the ways, with reasons
JavaScript has multiple function syntaxes, and juniors often learn one and use it everywhere without knowing why the others exist. I teach all of them alongside the specific problem each one solves.
Function declarations are hoisted. That matters:
// This works
greet('Harry');
function greet(name) {
console.log(`Hello, ${name}`);
}
Function expressions are not hoisted. That matters differently:
// This throws: cannot access 'greet' before initialization
greet('Harry');
const greet = function (name) {
console.log(`Hello, ${name}`);
};
Arrow functions are not just shorthand. They don’t have their own this. That matters a lot:
const timer = {
seconds: 0,
start() {
// Arrow function — 'this' refers to the timer object
setInterval(() => {
this.seconds++;
console.log(this.seconds);
}, 1000);
},
};
timer.start(); // 1, 2, 3, 4...
Compare with:
const timer = {
seconds: 0,
start() {
// Regular function — 'this' is undefined in strict mode
setInterval(function () {
this.seconds++; // 'this' is not the timer
console.log(this.seconds); // NaN
}, 1000);
},
};
The this conversation is one I have slowly and carefully, because it’s one of the most misunderstood concepts in JavaScript. I use the rule: this is determined by how a function is called, not where it’s defined — and then I spend an hour going through the four invocation patterns: direct call, method call, new call, and explicit binding with call, apply, and bind.
Stage Two: The Concepts That Define JavaScript
Once the basics are solid, I move to the concepts that are specifically and distinctively JavaScript. These are the things that confuse developers who come from other languages, and the things that trip up developers who learned JavaScript through frameworks without understanding the language itself.
Closures — the most important thing to understand
I introduce closures through a problem, not a definition:
// Task: write a function that counts how many times it has been called.
// The count must be private — it can't be accessible from outside the function.
Most juniors try a global variable first. I let them, and then I show them the problem with global state. Then I ask: how could the function remember something without putting it in a global variable?
After some thinking, we arrive at the closure:
function makeCounter() {
let count = 0; // Private to makeCounter's scope
return function () {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
console.log(count); // ReferenceError — can't access it from outside
Once closures click, I show them where they appear in the real world — in every event listener they’ve ever written, in module patterns, in React’s useState hook (which is fundamentally a closure around a value), in Angular service instances.
// This is a closure. The event listener 'closes over' the message variable.
function setupButton(buttonId, message) {
const button = document.getElementById(buttonId);
button.addEventListener('click', function () {
// 'message' is captured from the outer function's scope
alert(message);
});
}
setupButton('btn-1', 'First button clicked');
setupButton('btn-2', 'Second button clicked');
The event loop — why JavaScript does what it does
This is the concept I wish someone had taught me explicitly and early. Without it, async JavaScript feels like magic. With it, every async behaviour makes sense.
I tell students about the event loop that there are three components of it:
- The call stack — where synchronous code executes
- The Web APIs — where async operations wait (timers, HTTP requests, DOM events)
- The task queue (and microtask queue) — where callbacks wait to re-enter the call stack
Then I walk through this code manually:
console.log('1 - start');
setTimeout(() => {
console.log('3 - timeout');
}, 0);
Promise.resolve().then(() => {
console.log('2 - promise');
});
console.log('4 - end');
Most juniors expect: 1, 3, 2, 4. The actual output is: 1, 4, 2, 3.
Walking through why — the synchronous code runs first, then the microtask queue (Promises), then the task queue (setTimeout) — gives the developer a permanent mental model for async JavaScript behaviour. After this exercise, async/await is no longer magic. It’s syntactic sugar over Promises, which are a mechanism for working with the task queue.
Prototypal inheritance — before classes, always
Modern JavaScript has the class keyword. It’s clean, it’s readable, and it’s what I recommend for production code. But I teach prototypal inheritance first, because class is syntactic sugar over prototypes, and the bugs that arise from class-based code in JavaScript are prototype bugs.
// This is what's really happening when you use a class
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
return `${this.name} makes a sound.`;
};
function Dog(name) {
Animal.call(this, name); // Call parent constructor
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.speak = function () {
return `${this.name} barks.`;
};
const d = new Dog('Rex');
console.log(d.speak()); // 'Rex barks.'
console.log(d instanceof Animal); // true
After building this manually, I show the class equivalent:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound.`;
}
}
class Dog extends Animal {
speak() {
return `${this.name} barks.`;
}
}
The class syntax is cleaner. But now the developer knows what it’s doing underneath — and when they encounter a this binding bug in a class method, they can reason about it.
Stage Three: Asynchronous JavaScript in Depth
Async is where most junior developers develop genuine anxiety. They’ve been burned by callback hell, confused by Promise chaining, and mystified by why async/await sometimes doesn’t behave the way they expect. I dedicate significant time here.
Callbacks first — even though we won’t use them much
I teach callbacks not because I want developers to write callback-heavy code, but because Promises and async/await only make sense in contrast to what they replaced.
// Fetching data with a callback — the old way
function fetchUser(userId, onSuccess, onError) {
setTimeout(() => {
if (userId === 1) {
onSuccess({ id: 1, name: 'Harry' });
} else {
onError(new Error('User not found'));
}
}, 500);
}
fetchUser(
1,
(user) => console.log('Got user:', user),
(err) => console.error('Error:', err)
);
Then I show callback hell — the deeply nested structure that emerges when you chain multiple async operations:
fetchUser(
1,
(user) => {
fetchTickets(
user.id,
(tickets) => {
fetchTicketDetails(
tickets[0].id,
(detail) => {
fetchComments(
detail.id,
(comments) => {
// By this point, you're 4 levels deep and the code is unreadable
console.log(comments);
},
handleError
);
},
handleError
);
},
handleError
);
},
handleError
);
The visceral experience of writing this is the best advertisement for Promises that exists.
Promises — the mental model, not just the syntax
// The same chain with Promises — flat, readable
fetchUser(1)
.then((user) => fetchTickets(user.id))
.then((tickets) => fetchTicketDetails(tickets[0].id))
.then((detail) => fetchComments(detail.id))
.then((comments) => console.log(comments))
.catch((err) => console.error('Something went wrong:', err));
I teach three key concepts about Promises:
1. A Promise is a container for a future value.
const promise = new Promise((resolve, reject) => {
// This executes immediately, synchronously
setTimeout(() => {
// But this executes later, asynchronously
resolve('Here is your value');
}, 1000);
});
// The .then callback runs when the promise resolves
promise.then((value) => console.log(value));
2. .then() always returns a new Promise. This is what makes chaining work, and it’s what most juniors don’t fully grasp:
const result = fetchUser(1)
.then((user) => {
// Returning a plain value wraps it in a resolved Promise
return user.name;
})
.then((name) => {
// Returning another Promise chains them — no nesting needed
return fetchProfile(name);
});
3. Error handling propagates automatically. A rejected Promise skips .then() handlers and falls through to the nearest .catch(). Understanding this eliminates a category of bugs I’ve seen repeated on every team I’ve worked on.
Async/Await — syntactic sugar that deserves respect
// The same chain with async/await — reads like synchronous code
async function loadTicketComments(userId) {
try {
const user = await fetchUser(userId);
const tickets = await fetchTickets(user.id);
const detail = await fetchTicketDetails(tickets[0].id);
const comments = await fetchComments(detail.id);
return comments;
} catch (err) {
console.error('Something went wrong:', err);
throw err; // Re-throw if the caller needs to handle it
}
}
Three things I always cover:
Parallel vs sequential. This is one of the most common performance mistakes I’ve seen in production code:
// Sequential — each awaits the previous one. Total time: 3 seconds.
const user = await fetchUser(1); // 1 second
const settings = await fetchSettings(1); // 1 second
const notifications = await fetchNotifications(1); // 1 second
// Parallel — all start simultaneously. Total time: ~1 second.
const [user, settings, notifications] = await Promise.all([
fetchUser(1),
fetchSettings(1),
fetchNotifications(1),
]);
In one of my projects, I found several places where sequential awaits were adding seconds to page load time. The data could have been fetched in parallel — the calls were completely independent.
Async/await in loops. Another common trap:
const ids = [1, 2, 3, 4, 5];
// Wrong: forEach doesn't await properly
// This fires all requests immediately with no sequential control
ids.forEach(async (id) => {
const result = await fetchItem(id);
console.log(result);
});
// Correct sequential: for...of awaits each iteration
for (const id of ids) {
const result = await fetchItem(id);
console.log(result);
}
// Correct parallel: map + Promise.all
const results = await Promise.all(ids.map((id) => fetchItem(id)));
Forgetting to await. A silent bug that produces mysterious behaviour:
async function saveTicket(ticket) {
updateDatabase(ticket); // Forgot await — function continues immediately
showSuccessMessage(); // This runs before the database update completes
}
async function saveTicket(ticket) {
await updateDatabase(ticket); // Correct
showSuccessMessage();
}
Stage Four: Modern JavaScript Patterns
By this stage, the developer has the mental models for the language itself. Now I introduce the modern patterns that make JavaScript code readable, maintainable, and efficient.
Destructuring — more than convenience
// Object destructuring
const terminal = {
id: 'T-001',
type: 'ATM',
location: { city: 'Mumbai', storeId: 'S-204' },
status: { online: true, lastReboot: new Date() },
};
// Without destructuring
const id = terminal.id;
const city = terminal.location.city;
// With destructuring — and renaming
const {
id,
location: { city },
} = terminal;
// Default values
const { id, type = 'POS' } = terminal;
// In function parameters — common in React/Angular
function renderTerminal({ id, type, status: { online } }) {
return `${id} (${type}): ${online ? 'online' : 'offline'}`;
}
Spread and rest — the same operator, two roles
// Rest in function parameters — collect remaining arguments
function logEvent(type, ...details) {
console.log(`[${type}]`, ...details);
}
logEvent('GPS', 'vehicle-01', 48.8566, 2.3522);
// Spread in function calls — expand an array into arguments
const coords = [48.8566, 2.3522];
console.log(Math.max(...coords));
// Spread for immutable object updates — critical in React/Angular state
const state = { loading: false, data: null, error: null };
// Wrong: mutates original object
state.loading = true;
// Right: creates new object — Angular/React can detect the change
const newState = { ...state, loading: true };
// Spread for array operations without mutation
const vehicles = ['V-01', 'V-02', 'V-03'];
const withNew = [...vehicles, 'V-04']; // Add
const withoutFirst = vehicles.slice(1); // Remove
const updated = vehicles.map((v, i) => (i === 1 ? 'V-02-updated' : v)); // Update
Array methods that replace loops
I actively discourage juniors from reaching for for loops when a declarative array method communicates intent more clearly:
const alerts = [
{ id: 1, severity: 'critical', vehicleId: 'V-01', resolved: false },
{ id: 2, severity: 'warning', vehicleId: 'V-02', resolved: true },
{ id: 3, severity: 'critical', vehicleId: 'V-03', resolved: false },
{ id: 4, severity: 'info', vehicleId: 'V-01', resolved: false },
];
// filter — which alerts need attention?
const activeAlerts = alerts.filter((a) => !a.resolved);
// map — transform the shape
const alertSummaries = alerts.map((a) => ({
id: a.id,
label: `${a.severity.toUpperCase()} — ${a.vehicleId}`,
}));
// find — get the first match
const firstCritical = alerts.find((a) => a.severity === 'critical');
// reduce — aggregate into something new
const alertsByVehicle = alerts.reduce((acc, alert) => {
if (!acc[alert.vehicleId]) acc[alert.vehicleId] = [];
acc[alert.vehicleId].push(alert);
return acc;
}, {});
// Chaining — readable pipeline
const unresolvedCriticalVehicles = alerts
.filter((a) => !a.resolved && a.severity === 'critical')
.map((a) => a.vehicleId);
// ['V-01', 'V-03']
The mental model I teach: each method is a question.
filterasks “which ones pass this test?”mapasks “what does each one become?”findasks “which is the first one that passes?”reduceasks “what does this whole collection add up to?”
Stage Five: The Concepts That Separate Good from Great
This is where mentoring gets interesting. The concepts in this section are the ones that senior developers reach for naturally and juniors haven’t been explicitly taught. I call them “invisible architecture” — not because they’re hidden, but because nobody told the junior they exist.
Immutability and why it matters in frontend
I spend a dedicated session on the difference between mutation and transformation, because this is the root cause of a significant proportion of bugs I’ve debugged in Angular and React codebases.
// Mutation — modifying the original
function addAlert(alertList, newAlert) {
alertList.push(newAlert); // Mutates the array
return alertList;
}
// Transformation — producing a new value
function addAlert(alertList, newAlert) {
return [...alertList, newAlert]; // New array, original untouched
}
Why does this matter in Angular and React? Because change detection in both frameworks often relies on reference equality — has this object or array been replaced with a different reference? Mutation changes the contents of the array but not the reference. Angular’s OnPush change detection and React’s shouldComponentUpdate both miss mutations.
const alerts = [{ id: 1, severity: 'critical' }];
// Mutation — Angular/React may not detect this change
alerts.push({ id: 2, severity: 'warning' });
// Same reference — change detection might skip this component
// Transformation — change is detectable
const newAlerts = [...alerts, { id: 2, severity: 'warning' }];
// New reference — change detection will pick this up
Error handling as a first-class concern
Junior developers handle the happy path and leave error handling as an afterthought. I teach it as a design concern.
// Naive — errors are unhandled silently
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const data = await fetchDashboardData(user.id);
renderDashboard(data);
}
// Production-ready — every failure mode is explicit
async function loadDashboard(userId) {
try {
const user = await fetchUser(userId);
if (!user) {
showError('User not found. Please log in again.');
redirectToLogin();
return;
}
const data = await fetchDashboardData(user.id);
renderDashboard(data);
} catch (err) {
if (err.status === 401) {
// Authentication error — redirect
redirectToLogin();
} else if (err.status === 403) {
// Authorisation error — show appropriate message
showError('You do not have permission to view this dashboard.');
} else {
// Unknown error — log and show generic message
console.error('Dashboard load failed:', err);
showError('Something went wrong. Please try again.');
}
}
}
In another of my projects, the event handlers needed careful error handling — a bad payload or a dropped connection shouldn’t crash the entire monitoring interface. Teaching juniors to think about failure modes before writing the happy path changed the quality of code reviews significantly.
Module patterns and code organisation
// Feature module pattern — what I teach before Angular modules make sense
// alerts.js
// Private — not exported
const BASE_URL = '/api/alerts';
function formatAlert(raw) {
return {
id: raw.id,
severity: raw.severity_level,
message: raw.alert_message,
timestamp: new Date(raw.created_at),
resolved: raw.is_resolved === 1,
};
}
// Public — exported
export async function fetchAlerts(vehicleId) {
const response = await fetch(`${BASE_URL}?vehicleId=${vehicleId}`);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
const raw = await response.json();
return raw.map(formatAlert);
}
export async function resolveAlert(alertId) {
const response = await fetch(`${BASE_URL}/${alertId}/resolve`, {
method: 'PATCH',
});
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
return response.json();
}
I use module organisation exercises as a proxy for thinking about separation of concerns — a concept that matters as much in Angular services and React hooks as it does in plain JavaScript modules.
The Project Sequence I Assign
I don’t teach JavaScript through isolated exercises. I assign a sequence of projects, each one building on the last, each one introducing friction that surfaces new concepts.
| Project | Concepts it surfaces |
|---|---|
| Static to-do list | DOM manipulation, event listeners, closures |
| To-do list with local storage | JSON, serialisation, state persistence |
| Weather app | fetch, Promises, async/await, error handling |
| Filterable table | Array methods, event handling, performance |
| Real-time price ticker | WebSockets, closures over connection state |
| Mini state manager | Immutability, observer pattern, closures |
| Module-based dashboard | Module organisation, separation of concerns |
The rule: no frameworks. If the project is hard without a framework, that’s the point.
How I Give Feedback
The way you give feedback matters as much as what you say. I’ve learned this through years of code reviews.
I ask questions before I give answers. “What were you trying to do here?” before “You should have done X.” Most of the time, the question reveals a misunderstanding that explaining the ‘correct’ approach alone wouldn’t fix.
I explain the why, not just the what. “Use const here” is weak feedback. “Use const here because this reference shouldn’t be reassigned, and using const makes that intent explicit to every developer who reads this code” is feedback that builds understanding.
I point out what’s good. Junior developers are often told only what’s wrong. When I see a clean destructuring pattern, a well-named variable, a properly handled async error — I call it out. It builds the pattern recognition for what good code looks and feels like.
I let bugs breathe for a bit. When a junior developer hits a bug, I don’t immediately explain it. I ask questions. “What does this line do?” “What value do you think this variable holds at this point?” The process of reasoning through a bug is more valuable than the resolution.
The Moment I Know It’s Working
There’s a specific moment in mentoring junior developers that I watch for. It’s not when they start writing clean code — that comes later. It’s when they start asking different questions.
Early-stage questions sound like: “How do I do X?” “What’s the syntax for Y?”
Later-stage questions sound like: “Why does this behave this way?” “Is this the right tool for this problem?” “What happens if this fails?”
That shift — from syntax questions to reasoning questions — is when I know the mental models are starting to form. At that point, the developer doesn’t need me to explain the next concept. They’re developing the curiosity and the reasoning process to figure it out themselves.
That’s the goal. Not to teach everything, but to build the instinct to learn the rest.
Conclusion
I’ve mentored junior developers on codebases at very different scales — from small agency projects in vanilla JavaScript to large enterprise Angular applications serving millions of users. The thing that consistently separates developers who grow quickly from those who stay stuck is not the amount of JavaScript they know. It’s the quality of their mental models about how JavaScript works.
Teaching those mental models — through deliberate sequencing, real projects, thoughtful feedback, and questions that make developers reason rather than receive — is the most valuable thing I’ve done as a technical lead.
If you’re mentoring someone: slow down on the fundamentals more than feels comfortable. The developers who understand closures, the event loop, and immutability write better Angular and React code than developers who learned those frameworks first. Not eventually — immediately.
If you’re being mentored: demand explanations of why, not just how. Every time someone tells you to do something, ask what problem it solves. Build the mental model. The syntax you can look up anytime. The understanding has to be earned once and then it’s yours.
Resources
- You Don’t Know JS (book series) — Kyle Simpson’s deep-dive series is the best JavaScript fundamentals resource I’ve found
- javascript.info — the most complete and readable JavaScript reference on the web
- The Event Loop — Philip Roberts (JSConf EU) — the single best explanation of the event loop I know of
- Eloquent JavaScript — excellent for building problem-solving intuition alongside language knowledge
- MDN Web Docs — the authoritative reference, always