A quiz app is the best small project for learning state — the idea that your app has a current situation, and the screen is drawn from it.
The HTML#
<main class="quiz">
<div id="progress"></div>
<h2 id="question"></h2>
<div id="answers"></div>
<button id="next" hidden>Next</button>
</main>
<script src="quiz.js" defer></script>Step 1: questions as data#
const QUESTIONS = [
{
question: "What does a variable do?",
answers: [
"Stores a value for later use",
"Deletes a file",
"Slows the program down",
"Prints to the screen",
],
correct: 0,
},
{
question: "Which keyword defines a function in Python?",
answers: ["func", "def", "function", "define"],
correct: 1,
},
{
question: "What does === check in JavaScript?",
answers: [
"Value only",
"Value and type",
"Nothing useful",
"Whether a variable exists",
],
correct: 1,
},
];Keeping questions as plain data means you can add fifty more without touching a line of display code. That separation is the single most valuable habit in this project.
Step 2: the state#
let current = 0;
let score = 0;
let answered = false;
const progressEl = document.querySelector("#progress");
const questionEl = document.querySelector("#question");
const answersEl = document.querySelector("#answers");
const nextBtn = document.querySelector("#next");Three variables describe the entire app at any moment. Everything on screen is derived from them.
Step 3: rendering a question#
function renderQuestion() {
answered = false;
nextBtn.hidden = true;
const q = QUESTIONS[current];
progressEl.textContent = `Question ${current + 1} of ${QUESTIONS.length}`;
questionEl.textContent = q.question;
answersEl.innerHTML = "";
q.answers.forEach((text, index) => {
const button = document.createElement("button");
button.className = "answer";
button.textContent = text;
button.dataset.index = index;
answersEl.appendChild(button);
});
}Step 4: checking the answer#
answersEl.addEventListener("click", (event) => {
const button = event.target.closest(".answer");
if (!button || answered) return; // ignore double clicks
answered = true;
const chosen = Number(button.dataset.index);
const correct = QUESTIONS[current].correct;
if (chosen === correct) score++;
for (const b of answersEl.children) {
const index = Number(b.dataset.index);
if (index === correct) b.classList.add("correct");
else if (index === chosen) b.classList.add("wrong");
b.disabled = true;
}
nextBtn.hidden = false;
nextBtn.textContent = current === QUESTIONS.length - 1 ? "See results" : "Next";
});The answered flag stops a fast clicker scoring twice on one question — a bug that is easy to miss and obvious once someone finds it.
Step 5: moving on and finishing#
nextBtn.addEventListener("click", () => {
current++;
if (current < QUESTIONS.length) {
renderQuestion();
} else {
renderResults();
}
});
function renderResults() {
const percent = Math.round((score / QUESTIONS.length) * 100);
progressEl.textContent = "Finished";
questionEl.textContent = `You scored ${score} out of ${QUESTIONS.length} (${percent}%)`;
answersEl.innerHTML = "";
nextBtn.hidden = true;
const again = document.createElement("button");
again.textContent = "Try again";
again.addEventListener("click", () => {
current = 0;
score = 0;
renderQuestion();
});
answersEl.appendChild(again);
}
renderQuestion();The CSS that makes feedback obvious#
.answer {
display: block;
width: 100%;
text-align: left;
padding: 12px 16px;
margin-bottom: 8px;
border: 1px solid #ddd;
border-radius: 10px;
background: #fff;
cursor: pointer;
font: inherit;
}
.answer:hover:not(:disabled) { border-color: #4f46e5; }
.answer.correct { background: #dcfce7; border-color: #16a34a; }
.answer.wrong { background: #fee2e2; border-color: #dc2626; }
.answer:disabled { cursor: default; }Colour alone is not enough for colour-blind users — adding a tick or cross character to the correct and wrong buttons would make this properly accessible.
Questions people ask#
How do I add a lot more questions?
Move them into a separate questions.json file and fetch it at startup. None of the display code needs to change, which is the reward for keeping them separate.
Can I stop people cheating by opening DevTools?
Not in the browser — anything sent to the page can be read. Real quizzes check answers on a server.
Why use one listener on the container?
Because the answer buttons are recreated for every question. A listener on the container survives; listeners on individual buttons would need reattaching each time.
Where to go next#
- Build a portfolio page
- Load your questions from an API
- JavaScript localStorage explained — for saving the best score