Module 10: Interview Questions
Coding Exercises with Solutions and Reasoning
Eight exercises of the kind that come up in React interviews, each with a solution and an explanation of why it works and what the follow-up questions will be.
Try each exercise yourself first. Spend at least ten minutes before reading the solution, even if you're stuck, because the struggling is the part that makes it stick. When you do compare, don't just check whether yours works. Check whether you can explain why it works, since that's what the interviewer is really grading.
In a live interview, talk while you code. Say what you're about to do and why. Silence looks the same as being lost.
Exercise 1: Fix the frozen counter#
This component should count up every second. It counts to 1 and stops. Fix it.
function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
Solution#
function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
Reasoning#
The effect runs once (empty dependency array) during the first render, where count is 0. The interval callback closes over that 0, so every tick calls setCount(0 + 1). It's a stale closure.
The updater form (c) => c + 1 doesn't read count from the closure. React passes the latest value in. That's the best fix because the effect keeps running once, with one interval.
Adding count to the dependency array would also work, but it tears down and recreates the interval every second, which is wasteful and can drift. A follow-up you might get: "what if the interval callback also needed to read a prop that changes?" Then mention a ref that holds the latest value, or useEffectEvent.
Exercise 2: Todo list#
Build a to-do list where you can add items, toggle them done, and delete them. No mutation, stable keys.
Solution#
import { useState } from "react";
export default function Todos() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState("");
function handleAdd(e) {
e.preventDefault();
const title = text.trim();
if (!title) return;
setTodos((prev) => [...prev, { id: crypto.randomUUID(), title, done: false }]);
setText("");
}
function toggle(id) {
setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
function remove(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
const remaining = todos.filter((t) => !t.done).length;
return (
<section>
<form onSubmit={handleAdd}>
<label htmlFor="todo">New todo</label>
<input id="todo" value={text} onChange={(e) => setText(e.target.value)} />
<button>Add</button>
</form>
<ul>
{todos.map((t) => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
<span style={{ textDecoration: t.done ? "line-through" : "none" }}>{t.title}</span>
</label>
<button onClick={() => remove(t.id)} aria-label={`Delete ${t.title}`}>x</button>
</li>
))}
</ul>
<p>{remaining} left</p>
</section>
);
}
Reasoning#
Everything is an immutable update. Add uses spread, toggle uses map with a copied object for the one changed item, and remove uses filter. The id is created in the handler, once, and used as the key, so it's stable across renders.
remaining is derived during render, not stored. Storing it as state is the mistake most candidates make here, and interviewers watch for it.
Small touches that show seniority: the form's onSubmit (Enter works), the trimmed input check, a label on the input, and an aria-label on the icon-only button.
Exercise 3: The wrong user#
This component sometimes shows the previous user's data after you click quickly between users. Why, and how do you fix it?
function UserProfile({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [id]);
return user ? <h2>{user.name}</h2> : <p>Loading...</p>;
}
Solution#
function UserProfile({ id }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setUser(null);
setError(null);
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [id]);
if (error) return <p>Could not load user.</p>;
return user ? <h2>{user.name}</h2> : <p>Loading...</p>;
}
Reasoning#
It's a race condition. Request for id 1 starts, then the id changes to 2 and a second request starts. If the first response arrives last, it calls setUser with user 1's data, overwriting user 2.
The cleanup function runs when id changes, and it aborts the in-flight request. The aborted fetch rejects with an AbortError, which we ignore because it's intentional. An alternative is the let ignore = false flag, which doesn't cancel the network request but prevents its result from being used.
Also notice that we reset user and error at the start of the effect, so the old user's name doesn't linger while the new one loads, and that we check res.ok because fetch doesn't reject on 404s. Say out loud that in a real app you'd use TanStack Query here, which gives you all of this by keying the cache on the id.
Exercise 4: Accordion#
Build an Accordion where only one section can be open at a time. Clicking the open section closes it. Make it reusable.
Solution#
import { useState } from "react";
function Accordion({ items }) {
const [openId, setOpenId] = useState(null);
return (
<div>
{items.map((item) => {
const isOpen = item.id === openId;
return (
<section key={item.id}>
<h3>
<button
aria-expanded={isOpen}
aria-controls={`panel-${item.id}`}
onClick={() => setOpenId(isOpen ? null : item.id)}
>
{item.title}
</button>
</h3>
{isOpen && (
<div id={`panel-${item.id}`} role="region">
{item.content}
</div>
)}
</section>
);
})}
</div>
);
}
// usage
<Accordion
items={[
{ id: "a", title: "Shipping", content: <p>Arrives in 3 days.</p> },
{ id: "b", title: "Returns", content: <p>30 day returns.</p> },
]}
/>
Reasoning#
The key design decision is what to store. I store openId, a single value, not an array of booleans or an isOpen flag per item. With one id, "only one open at a time" isn't a rule I have to enforce, it's impossible to violate. That's the "minimal state" idea.
content is JSX passed as data, so the accordion doesn't care what's inside it. For the follow-up "make it allow multiple open": change the state to an array or Set of ids, and toggle membership immutably. "Make it keyboard accessible": buttons already are, and aria-expanded tells screen readers the state.
Exercise 5: Debounced search#
Write a useDebounce hook, then a search box that only logs a search request 400 ms after the user stops typing.
Solution#
import { useEffect, useState } from "react";
export function useDebounce(value, delay = 400) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export default function Search() {
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 400);
useEffect(() => {
if (debounced.trim()) {
console.log("searching for", debounced); // real app: trigger a query keyed on `debounced`
}
}, [debounced]);
return (
<>
<label htmlFor="q">Search</label>
<input id="q" value={query} onChange={(e) => setQuery(e.target.value)} />
</>
);
}
Reasoning#
The input stays instantly responsive because query updates on every keystroke. debounced only catches up after a quiet period. The cleanup in useDebounce is the trick: when value changes before the timer fires, the cleanup cancels the pending timer, and a new one starts. Only the last pause survives.
Two follow-ups to be ready for. "Why is it a hook and not just inline?" Because the logic is reusable and has its own state and effect, which is exactly what custom hooks are for. "What about the request itself?" The request should depend on debounced, never on query, and ideally be cancelable, or handled by a query library whose key includes debounced.
Exercise 6: The jumping input#
Type something in the note field next to "Apples", then delete "Apples". The text ends up next to "Bananas". Why, and what's the fix?
function Fruits() {
const [fruits, setFruits] = useState(["Apples", "Bananas", "Cherries"]);
return (
<ul>
{fruits.map((name, index) => (
<li key={index}>
{name} <input placeholder="note" />
<button onClick={() => setFruits(fruits.filter((_, i) => i !== index))}>x</button>
</li>
))}
</ul>
);
}
Solution#
function Fruits() {
const [fruits, setFruits] = useState([
{ id: 1, name: "Apples" },
{ id: 2, name: "Bananas" },
{ id: 3, name: "Cherries" },
]);
return (
<ul>
{fruits.map((fruit) => (
<li key={fruit.id}>
{fruit.name} <input placeholder="note" />
<button onClick={() => setFruits((prev) => prev.filter((f) => f.id !== fruit.id))}>
x
</button>
</li>
))}
</ul>
);
}
Reasoning#
With index keys, after deleting the first item, "Bananas" moves to index 0. React sees key 0 before and after, concludes it's the same list item, and updates its text from "Apples" to "Bananas", but reuses the same DOM node, and the uncontrolled input's typed value lives on that node. So the note stays at position 0 and ends up looking like it belongs to Bananas.
The fix is a stable identity: each fruit carries its own id, and that's the key. Now React matches by identity, removes the right node, and each input stays with its fruit.
If asked when index keys are acceptable: static lists that are never reordered, filtered, or edited in the middle, and where items hold no state.
Exercise 7: Stop the needless re-renders#
Typing in the input re-renders the ExpensiveList on every keystroke. Fix it in two different ways.
function Page({ items }) {
const [text, setText] = useState("");
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ExpensiveList items={items} />
</>
);
}
Solution A: move the state down#
function SearchInput() {
const [text, setText] = useState("");
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function Page({ items }) {
return (
<>
<SearchInput />
<ExpensiveList items={items} />
</>
);
}
Solution B: memoize the list#
import { memo } from "react";
const ExpensiveList = memo(function ExpensiveList({ items }) {
// ... heavy render
});
Reasoning#
The list re-renders because its parent re-renders, and the parent re-renders because the state it owns changes. Props aren't the cause.
Solution A removes the cause. The state now lives in a component that has nothing to do with the list, so typing never re-renders Page. No hooks and no equality checks involved. It's my first choice whenever the state can be moved.
Solution B lets the list opt out. memo compares props shallowly, and items is the same reference across renders (as long as the parent doesn't create a new array each time), so React skips it. If Page also passed a callback, I'd need useCallback to keep it stable, otherwise memo would be defeated.
The thing an interviewer wants to hear: you profile first, and you prefer the structural fix over sprinkling memoization.
Exercise 8: Star rating#
Build a StarRating component: five stars, hovering previews the rating, clicking sets it, and it should work as a controlled component (value and onChange props).
Solution#
import { useState } from "react";
export default function StarRating({ value, onChange, max = 5 }) {
const [hover, setHover] = useState(0);
const display = hover || value;
return (
<div role="radiogroup" aria-label="Rating" onMouseLeave={() => setHover(0)}>
{Array.from({ length: max }, (_, i) => {
const star = i + 1;
return (
<button
key={star}
type="button"
role="radio"
aria-checked={value === star}
aria-label={`${star} star${star > 1 ? "s" : ""}`}
onMouseEnter={() => setHover(star)}
onClick={() => onChange(star)}
style={{ background: "none", border: "none", fontSize: 28, cursor: "pointer" }}
>
{star <= display ? "★" : "☆"}
</button>
);
})}
</div>
);
}
// usage
function Review() {
const [rating, setRating] = useState(0);
return <StarRating value={rating} onChange={setRating} />;
}
Reasoning#
There are two different pieces of state, and the exercise is about telling them apart. The rating is owned by the parent (controlled), because the parent probably needs it for a form submit. The hover value is purely a UI preview, owned by the component itself and never leaves it.
What's displayed is derived: hover || value. No effect and no third state.
Using real button elements gives keyboard focus and click handling for free. The radiogroup roles and aria-checked communicate the meaning to assistive tech. A complete answer would also add arrow-key navigation, but mentioning it as a next step is usually enough in a time-boxed interview.
Follow-ups: "support half stars", "make it read-only" (a readOnly prop that skips handlers), and "what if the parent doesn't pass value" (support an uncontrolled mode with an internal state fallback).
Practice#
- Redo all eight without looking, one a day, timing yourself. Aim for under fifteen minutes each.
- For each, write down one question an interviewer might ask next, and answer it.
- Pick two exercises and write tests for them with React Testing Library. Testing your own solution is a great way to find the edge cases you skipped.
- Invent three new exercises of your own based on bugs you've hit in your projects. The best interview prep is explaining a real bug you've fixed.