Chapter 10.3☕ 25 min read

Interactive Quiz App: Timers, State & Array Methods

State Machine banao, Reduce se count karo, Interval se time rakho, localStorage mein save karo!

01🎯 Quiz State Machine & Data Structure

Quiz apps have distinct phases: 'start', 'playing', and 'finished'. Never allow invalid transitions — a user shouldn't answer after time is up!

The data structure is crucial. You need an array of question objects, a separate answers map, and a current index tracker.

Do NOT mutate the original questions array. Always shuffle a copy using Fisher-Yates.

Keep score calculation separate from rendering — pure functions are easier to test and debug.

const state = {
  questions: [],      // Shuffled copy
  currentIndex: 0,
  answers: {},        // { 0: 'A', 1: 'C' }
  status: 'start',    // 'start' | 'playing' | 'finished'
  timeLeft: 30
};

function startQuiz(rawQuestions) {
  // Shuffle using Fisher-Yates
  const shuffled = [...rawQuestions];
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  
  state.questions = shuffled;
  state.currentIndex = 0;
  state.answers = {};
  state.status = 'playing';
  state.timeLeft = 30;
  startTimer();
  render();
}
State Machine thinking: By modeling quiz phases as explicit states, you prevent bugs like answering after time-up or clicking "Next" on the last question. Every action checks state.status first. This pattern is used in game development and UI frameworks like XState.
02🔢 Array Methods: Filter, Map & Reduce for Scoring

Use reduce to calculate the total score by comparing state.answers with state.questions.

Use map to render the review screen — transform each question into HTML showing correct/incorrect.

Use filter to extract only the incorrectly answered questions for focused review.

These methods make data transformation declarative and bug-free — no manual loops, no off-by-one errors.

// 1. Calculate Score using reduce
function calculateScore() {
  return state.questions.reduce((score, q, index) => {
    return state.answers[index] === q.correctAnswer
      ? score + 1 : score;
  }, 0);
}

// 2. Get Wrong Answers using filter
function getWrongAnswers() {
  return state.questions.filter((q, index) => {
    return state.answers[index] !== q.correctAnswer;
  });
}

// 3. Render Review using map
function renderReview() {
  const score = calculateScore();
  const wrong = getWrongAnswers();
  
  reviewDiv.innerHTML = state.questions.map((q, i) => {
    const userAnswer = state.answers[i] || 'Skipped';
    const isCorrect = userAnswer === q.correctAnswer;
    return '
' + '

Q' + (i+1) + ': ' + q.text + '

' + '

Your Answer: ' + userAnswer + '

' + (!isCorrect ? '

Correct: ' + q.correctAnswer + '

' : '') + '
'; }).join(''); }
Why reduce for scoring? reduce is perfect for "accumulate a single value from an array". Score is exactly that — start at 0, add 1 for each correct answer. The 0 at the end is the initial accumulator value. Without it, reduce uses the first element (an object), which would break the addition.
03⏰ Timers: setInterval & Cleanup

setInterval runs a function every X milliseconds. Perfect for countdown timers in quiz apps.

CRITICAL: You MUST store the interval ID and call clearInterval when the quiz ends or component unmounts. Otherwise — memory leak!

If the user clicks "Next", reset the timer. If the timer hits 0, auto-submit the current question.

Timer logic must interact safely with the state machine — never start a timer if status !== 'playing'.

let timerId = null;

function startTimer() {
  clearInterval(timerId); // Clear any existing timer
  state.timeLeft = 30;
  renderTimer();
  
  timerId = setInterval(() => {
    state.timeLeft--;
    renderTimer();
    
    if (state.timeLeft <= 0) {
      clearInterval(timerId);
      handleTimeUp();
    }
  }, 1000);
}

function handleTimeUp() {
  if (state.currentIndex < state.questions.length - 1) {
    state.currentIndex++;
    startTimer(); // Restart for next question
  } else {
    finishQuiz();
  }
  render();
}

function finishQuiz() {
  clearInterval(timerId); // MUST clear on finish!
  state.status = 'finished';
  render();
}
setInterval drift: setInterval(fn, 1000) does NOT guarantee exactly 1000ms. If the main thread is busy (heavy DOM updates), callbacks queue up and fire late. For production apps, use Date.now() to measure actual elapsed time instead of counting down with timeLeft--. The countdown approach drifts; the absolute time approach stays accurate.
04🖱️ Event Delegation for Answer Selection

Quiz options are dynamic — they change with every question. Use Event Delegation on the options container instead of attaching listeners to each button.

When an option is clicked, save the answer to state.answers and visually highlight it.

Use data-* attributes to map DOM clicks back to array indices — no closures needed.

const optionsContainer = document.getElementById('options');

optionsContainer.addEventListener('click', (e) => {
  if (state.status !== 'playing') return; // Guard clause
  
  const option = e.target.closest('.option');
  if (!option) return; // Click wasn't on an option
  
  const questionIndex = parseInt(option.dataset.qIndex);
  const answerValue = option.dataset.value;
  
  // Save answer to state
  state.answers[questionIndex] = answerValue;
  
  // Visual feedback
  optionsContainer.querySelectorAll('.option').forEach(opt => {
    opt.classList.remove('selected');
  });
  option.classList.add('selected');
});

// HTML Generation with data attributes
function renderQuestion() {
  const q = state.questions[state.currentIndex];
  optionsContainer.innerHTML = q.options.map(opt =>
    ''
  ).join('');
}
Why event delegation? Instead of attaching click handlers to 4 buttons per question x 10 questions = 40 handlers, you attach ONE handler to the container. When options change (new question), the handler still works — no re-binding needed. This is how production apps handle dynamic lists.
05🏆 Persisting High Scores in localStorage

Quiz apps need to track high scores across sessions using localStorage.

Load high scores on init. Update them only if the new score qualifies for the list.

Keep the array sorted descending and limit to top 5 scores to prevent localStorage from growing indefinitely.

function saveHighScore(score) {
  const highScores = loadHighScores();
  highScores.push({ score, date: new Date().toISOString() });
  
  // Sort descending by score
  highScores.sort((a, b) => b.score - a.score);
  
  // Keep only top 5 scores
  const top5 = highScores.slice(0, 5);
  
  localStorage.setItem('quiz_high_scores', JSON.stringify(top5));
}

function loadHighScores() {
  try {
    const data = localStorage.getItem('quiz_high_scores');
    return data ? JSON.parse(data) : [];
  } catch {
    return []; // Corrupted data? Start fresh.
  }
}
Always cap arrays you store in localStorage (like top 5 scores or last 10 searches). If you don't, the array can grow indefinitely, exceeding the 5MB localStorage limit and causing QuotaExceededError on write. Prevention is better than debugging.

Lo kar liya — Key Points:

  • ✅ Model quiz logic as a State Machine with explicit statuses ('start', 'playing', 'finished') to prevent invalid actions
  • ✅ Use reduce for score calculation, filter for extracting wrong answers, and map for rendering review lists
  • ✅ Always store setInterval IDs and call clearInterval on quiz end or component unmount to prevent memory leaks
  • ✅ Use Event Delegation on the options container to handle dynamic answer buttons efficiently
  • ✅ Save and sort high scores in localStorage, capping the list size to avoid exceeding storage limits
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase