BeginnerControl flow~30 min

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.

Tracing a loop by hand — the skill that transfers to debugging any language
Line executeditotalNotes
for (let i = 1; i <= 3; i++) {10Loop starts
total += i;11total becomes 0 + 1
i++ (loop update)21Condition 2 <= 3 still true
total += i;23total becomes 1 + 2
i++ (loop update)33Condition 3 <= 3 still true
total += i;36total becomes 3 + 3
i++ (loop update)46Condition 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:

JavaScriptGuard clause versus nested 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

What does this log? for (let i = 0; i < 4; i++) { if (i === 1) continue; if (i === 3) break; console.log(i); }
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.

A switch statement's case 'loading' block has no break and no return. What happens if status is 'loading'?
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.

  1. Hint 1

    result starts at 1, and i runs from 1 through 4 inclusive.

  2. Hint 2

    Each iteration multiplies result by the current i — this computes a factorial.

  3. 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) -> 24

Practice: 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.

  1. Hint 1

    The remainder operator % tells you divisibility: n % 3 === 0 means n is divisible by 3.

  2. Hint 2

    Check "divisible by both" before checking either one alone, or check both conditions in a single combined branch first.

  3. 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 for let/const — a binding declared inside doesn’t exist outside.
  • Use if/else for arbitrary conditions, switch for one value against several exact matches, and the ternary only when you need a single resulting value.
  • switch cases fall through without an explicit break/return — always terminate each case deliberately.
  • Prefer for...of for iterating a collection’s values; use a classic for when you need the index, and while when the number of iterations isn’t known up front.
  • break exits a loop entirely; continue skips to the next iteration.
  • Guard clauses handle edge cases with an early exit, keeping the main logic flat instead of nested.