Project: Interactive Quiz App
A quiz app is a perfect exercise in coordinating several moving parts at once: a data model of questions, a UI rendered from that model, event handling for answers, a running score, and a countdown timer that ticks independently of the user. The key insight is the same one behind most front-end apps — keep all the moving values in one state object, and let the DOM be a pure function of that state. In this project you’ll build a complete vanilla-JS quiz with no frameworks and no build step: multiple-choice questions, per-question feedback, a timer that auto-advances, a live score, and a results screen.
Modeling the questions
Start with the data. Each question is a plain object with the prompt text, an array of options, and the index of the correct answer. Keeping the correct answer as an index (rather than the string) makes comparison cheap and avoids duplicate-text ambiguity.
const QUESTIONS = [
{
prompt: "Which keyword declares a block-scoped variable?",
options: ["var", "let", "function", "global"],
correct: 1,
},
{
prompt: "What does '===' check?",
options: ["Value only", "Type only", "Value and type", "Reference only"],
correct: 2,
},
{
prompt: "Which method converts a JSON string to an object?",
options: ["JSON.stringify", "JSON.parse", "Object.from", "toJSON"],
correct: 1,
},
];
The single state object
Rather than scattering variables across the file, hold everything the UI depends on in one object. Every handler mutates this object and then calls render(). This makes the data flow trivial to reason about: there is exactly one place to look when something is wrong.
const state = {
index: 0, // which question we're on
score: 0, // correct answers so far
selected: null, // index the user picked for the current question
finished: false,
};
Rendering from state
The render function looks at state and decides what to draw: either the current question with its options, or the results screen when finished is true. We tag each option button with a data-choice index so a single delegated listener can tell which one was clicked.
const render = () => {
if (state.finished) return renderResults();
const q = QUESTIONS[state.index];
app.innerHTML = `
<p class="progress">Question ${state.index + 1} of ${QUESTIONS.length}</p>
<h2>${q.prompt}</h2>
<div class="options">
${q.options
.map((opt, i) => `<button data-choice="${i}">${opt}</button>`)
.join("")}
</div>`;
};
Re-rendering replaces the option buttons every time, so never attach listeners to them directly — they’d be discarded on the next render. Use one delegated listener on a stable parent instead (shown below).
Handling answers and scoring
When the user clicks an option, compare its index to q.correct. If it matches, bump the score. We store the selection so we can highlight right/wrong before moving on, then advance after a short pause so the feedback is visible.
const choose = (choice) => {
if (state.selected !== null) return; // ignore double-clicks
state.selected = choice;
if (choice === QUESTIONS[state.index].correct) state.score++;
showFeedback();
setTimeout(next, 800);
};
const next = () => {
state.selected = null;
if (state.index + 1 < QUESTIONS.length) {
state.index++;
} else {
state.finished = true;
}
resetTimer();
render();
};
A countdown timer
The timer adds pressure and teaches you to manage setInterval cleanly. Keep one interval handle on the side; each tick decrements a counter and updates the display. When it hits zero, treat it as an unanswered question and advance. Always clear the previous interval before starting a new one to avoid stacking ghost timers.
let timerId = null;
let remaining = 0;
const resetTimer = () => {
clearInterval(timerId);
remaining = 10;
updateTimerUI();
timerId = setInterval(() => {
remaining--;
updateTimerUI();
if (remaining <= 0) next();
}, 1000);
};
| Concern | API | Why it matters |
|---|---|---|
| Start ticking | setInterval(fn, 1000) | Fires every second |
| Stop ticking | clearInterval(id) | Prevents overlapping timers |
| One-shot delay | setTimeout(fn, ms) | Pause to show feedback |
| Find clicked option | e.target.dataset.choice | Event delegation |
The complete app
Here is the whole quiz in one self-contained file — markup, styling, state, scoring, timer, and results. Answer a question to see green/red feedback, watch the timer auto-advance, and reach the results screen.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; max-width: 460px; margin: 2rem auto; }
.bar { display: flex; justify-content: space-between; font-weight: bold; }
.options { display: grid; gap: .5rem; margin-top: 1rem; }
.options button { padding: .6rem; font-size: 1rem; cursor: pointer; border: 1px solid #ccc; border-radius: 6px; background: #fff; }
.options button.correct { background: #d4f7d4; border-color: #34a853; }
.options button.wrong { background: #fad4d4; border-color: #ea4335; }
.progress { color: #666; }
.restart { margin-top: 1rem; padding: .6rem 1rem; cursor: pointer; }
</style>
</head>
<body>
<div class="bar">
<span id="score">Score: 0</span>
<span id="timer">10s</span>
</div>
<div id="app"></div>
<script>
const QUESTIONS = [
{ prompt: "Which keyword declares a block-scoped variable?", options: ["var", "let", "function", "global"], correct: 1 },
{ prompt: "What does '===' check?", options: ["Value only", "Type only", "Value and type", "Reference only"], correct: 2 },
{ prompt: "Which method parses a JSON string?", options: ["JSON.stringify", "JSON.parse", "Object.from", "toJSON"], correct: 1 },
];
const state = { index: 0, score: 0, selected: null, finished: false };
const app = document.querySelector("#app");
const scoreEl = document.querySelector("#score");
const timerEl = document.querySelector("#timer");
let timerId = null;
let remaining = 0;
const updateTimerUI = () => { timerEl.textContent = `${Math.max(remaining, 0)}s`; };
const resetTimer = () => {
clearInterval(timerId);
remaining = 10;
updateTimerUI();
timerId = setInterval(() => {
remaining--;
updateTimerUI();
if (remaining <= 0) next();
}, 1000);
};
const render = () => {
scoreEl.textContent = `Score: ${state.score}`;
if (state.finished) return renderResults();
const q = QUESTIONS[state.index];
app.innerHTML = `
<p class="progress">Question ${state.index + 1} of ${QUESTIONS.length}</p>
<h2>${q.prompt}</h2>
<div class="options">
${q.options.map((opt, i) => `<button data-choice="${i}">${opt}</button>`).join("")}
</div>`;
};
const showFeedback = () => {
const q = QUESTIONS[state.index];
const buttons = app.querySelectorAll("[data-choice]");
buttons.forEach((btn, i) => {
if (i === q.correct) btn.classList.add("correct");
else if (i === state.selected) btn.classList.add("wrong");
});
};
const choose = (choice) => {
if (state.selected !== null) return;
clearInterval(timerId);
state.selected = choice;
if (choice === QUESTIONS[state.index].correct) state.score++;
scoreEl.textContent = `Score: ${state.score}`;
showFeedback();
setTimeout(next, 800);
};
const next = () => {
state.selected = null;
if (state.index + 1 < QUESTIONS.length) {
state.index++;
resetTimer();
} else {
state.finished = true;
clearInterval(timerId);
timerEl.textContent = "Done";
}
render();
};
const renderResults = () => {
const pct = Math.round((state.score / QUESTIONS.length) * 100);
const msg = pct === 100 ? "Perfect!" : pct >= 50 ? "Nice work!" : "Keep practicing!";
app.innerHTML = `
<h2>${msg}</h2>
<p>You scored ${state.score} / ${QUESTIONS.length} (${pct}%).</p>
<button class="restart">Play again</button>`;
};
app.addEventListener("click", (e) => {
const choiceBtn = e.target.closest("[data-choice]");
if (choiceBtn) return choose(Number(choiceBtn.dataset.choice));
if (e.target.classList.contains("restart")) {
state.index = 0; state.score = 0; state.selected = null; state.finished = false;
resetTimer();
render();
}
});
resetTimer();
render();
</script>
</body>
</html>
Computing the result summary
The results logic is pure data work, easy to test in isolation. Given a score and total, derive a percentage and a message — no DOM required:
const grade = (score, total) => {
const pct = Math.round((score / total) * 100);
const message = pct === 100 ? "Perfect!" : pct >= 50 ? "Nice work!" : "Keep practicing!";
return { pct, message };
};
console.log(grade(3, 3));
console.log(grade(1, 3));
Output:
{ pct: 100, message: 'Perfect!' }
{ pct: 33, message: 'Keep practicing!' }
Best Practices
- Keep one
stateobject as the single source of truth and re-render from it, rather than mutating individual DOM nodes ad hoc. - Store the correct answer as an index, not a string, so comparison is exact and option text can repeat safely.
- Use event delegation on a stable parent so option buttons created each render never need their own listeners.
- Always
clearIntervalbefore starting a new timer to prevent overlapping intervals that fire too fast. - Guard handlers against double input (
if (state.selected !== null) return) so a fast clicker can’t score twice. - Separate pure logic (scoring, grading) from rendering — it’s easier to reason about and test.
- Reset all state explicitly on “Play again” so a new round can’t inherit stale values.