Control Flow
Directing which statements run, and how many times, with conditionals and loops — plus writing both so they stay readable as they grow.
By the end of this lesson, you can
- Choose between if/else, switch, and the ternary operator based on how many branches and what shape of result you need
- Choose among for, while, and for...of based on whether you're counting, waiting on a condition, or iterating a collection
- Use break, continue, and guard clauses to avoid unnecessary nesting
- Trace a small program with a loop and a conditional by hand and predict its output
Why it matters
A program that only ever runs top to bottom can’t make decisions or repeat work. Control flow — branching and looping — is what turns a fixed sequence of statements into something that responds to its input. Getting comfortable tracing control flow by hand is one of the highest-leverage debugging skills there is.
Mental model
Every { } block introduces its own scope: a let or const declared inside an if, for, or
bare block doesn’t exist outside it. This matters immediately once you start branching:
if (true) {
let message = 'inside';
console.log(message); // "inside"
}
console.log(message); // ReferenceError: message is not defined
Branching statements pick which code runs; loops pick how many times a piece of code runs. Both categories have more than one tool, and picking the right one is mostly about matching the shape of the problem.
Branching: if/else, switch, and the ternary operator
// if/else — arbitrary conditions, any number of branches
if (score >= 90) {
grade = 'A';
} else if (score >= 80) {
grade = 'B';
} else {
grade = 'C or below';
}
// switch — one value compared against several exact matches
switch (status) {
case 'loading':
message = 'Please wait…';
break;
case 'error':
message = 'Something went wrong.';
break;
default:
message = 'Ready.';
}
// ternary — a single expression that produces one of two values
const label = itemCount === 1 ? 'item' : 'items';
Looping: for, while, and for…of
// for — you know how many times, or need an index
for (let i = 0; i < 5; i++) {
console.log(i);
}
// while — repeat until a condition becomes false, count unknown up front
let attempts = 0;
while (attempts < maxAttempts && !success) {
attempts++;
success = tryConnect();
}
// for...of — iterate the values of an array (or any iterable) directly
for (const item of ['apple', 'banana', 'cherry']) {
console.log(item);
}
for...of is almost always the right choice for “do something with each element of this array” —
it reads more clearly than tracking an index by hand, and it can’t produce an off-by-one error.
Reach for a classic for loop when you specifically need the index, or aren’t iterating a
collection at all.
| Line executed | i | total | Notes |
|---|---|---|---|
| for (let i = 1; i <= 3; i++) { | 1 | 0 | Loop starts |
| total += i; | 1 | 1 | total becomes 0 + 1 |
| i++ (loop update) | 2 | 1 | Condition 2 <= 3 still true |
| total += i; | 2 | 3 | total becomes 1 + 2 |
| i++ (loop update) | 3 | 3 | Condition 3 <= 3 still true |
| total += i; | 3 | 6 | total becomes 3 + 3 |
| i++ (loop update) | 4 | 6 | Condition 4 <= 3 is false — loop ends |
break, continue, and guard clauses
break exits a loop (or switch) immediately; continue skips to the next iteration without
finishing the current one:
for (const num of [1, 2, 3, 4, 5]) {
if (num === 3) continue; // skip 3, keep going
if (num === 5) break; // stop entirely once we hit 5
console.log(num); // logs 1, 2, 4
}
A guard clause is an early return (or continue/break) that handles an edge case up front,
so the rest of the function doesn’t need to nest inside an if:
// Nested — nesting grows with every additional condition
function describeUser(user) {
if (user) {
if (user.isActive) {
return `${user.name} is active`;
} else {
return `${user.name} is inactive`;
}
} else {
return 'No user';
}
}
// Guard clauses — each edge case exits early, main logic stays flat
function describeUserGuarded(user) {
if (!user) return 'No user';
if (!user.isActive) return `${user.name} is inactive`;
return `${user.name} is active`;
}Check your understanding
Or reveal the answer without checking
Answer:0 2
i=0 logs. i=1 hits continue and is skipped. i=2 logs. i=3 hits break before logging, so the loop stops. Result: 0, 2.
Or reveal the answer without checking
Answer:Execution falls through into the next case block too
Without break, return, or throw, execution 'falls through' into the following case's code regardless of whether its condition matches — a common source of bugs.
Practice: warm-up
Trace this loop by hand — write down the value of result after each iteration — before running it:
let result = 1;
for (let i = 1; i <= 4; i++) {
result = result * i;
}
console.log(result);
Stuck? Reveal one hint at a time.
Hint 1
result starts at 1, and i runs from 1 through 4 inclusive.
Hint 2
Each iteration multiplies result by the current i — this computes a factorial.
Hint 3
1 * 1 * 2 * 3 * 4
Reveal the trace
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
i=1: result = 1 * 1 = 1
i=2: result = 1 * 2 = 2
i=3: result = 2 * 3 = 6
i=4: result = 6 * 4 = 24
console.log(result) -> 24Practice: apply it
Write fizzBuzz(n) that returns "Fizz" if n is divisible by 3, "Buzz" if divisible by 5,
"FizzBuzz" if divisible by both, and String(n) otherwise. Then use a loop to print the result
for 1 through 20.
Stuck? Reveal one hint at a time.
Hint 1
The remainder operator % tells you divisibility: n % 3 === 0 means n is divisible by 3.
Hint 2
Check "divisible by both" before checking either one alone, or check both conditions in a single combined branch first.
Hint 3
A guard-clause style — checking the most specific case (both) first and returning immediately — avoids nested ifs here.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
function fizzBuzz(n) {
if (n % 15 === 0) return 'FizzBuzz';
if (n % 3 === 0) return 'Fizz';
if (n % 5 === 0) return 'Buzz';
return String(n);
}
for (let i = 1; i <= 20; i++) {
console.log(fizzBuzz(i));
}Summary
{ }blocks create their own scope forlet/const— a binding declared inside doesn’t exist outside.- Use
if/elsefor arbitrary conditions,switchfor one value against several exact matches, and the ternary only when you need a single resulting value. switchcases fall through without an explicitbreak/return— always terminate each case deliberately.- Prefer
for...offor iterating a collection’s values; use a classicforwhen you need the index, andwhilewhen the number of iterations isn’t known up front. breakexits a loop entirely;continueskips to the next iteration.- Guard clauses handle edge cases with an early exit, keeping the main logic flat instead of nested.