React Hooks Interview Mastery — From Quiz Answers to Senior‑Level Patterns
Most React interviews don’t ask you to write a whole app — they poke your mental model of Hooks with “simple” questions:
-
When should I use
useRefinstead ofuseState? -
Why does
useEffectreturn a cleanup function? -
Is it better to have one big
useEffector many small ones? -
Why can’t I write
useEffect(async () => { ... })? - How do custom hooks actually improve my code?
In this post we’ll turn those questions into production‑ready patterns. Each section starts from a quiz‑style idea (like the ones you’d see in an interview) and then shows what it really means in real code.
You can drop these explanations directly into code reviews, brown‑bag sessions, or your next technical interview.
1. useState vs useRef — Renders vs. Mutable References
Key idea: Updating
useStatere-renders the component. UpdatinguseRef.currentdoes not.
That’s the entire difference — but it has huge consequences for how you model UI.
import { useRef, useState } from "react";
export function StateVsRefDemo() {
const [count, setCount] = useState(0);
const renderCountRef = useRef(0);
// This will increment on every render
renderCountRef.current++;
return (
<div>
<p>Clicked: {count}</p>
<p>Component rendered: {renderCountRef.current} times</p>
<button onClick={() => setCount(c => c + 1)}>
Increment (triggers re-render)
</button>
<button
onClick={() => {
// This changes the ref but does NOT re-render
renderCountRef.current = 0;
console.log("Ref changed, but UI didn't re-render");
}}
>
Reset render counter (no re-render)
</button>
</div>
);
}
When to use which:
- ✅
useStatefor reactive state — anything that should show up in the UI, or that drives component logic. - ✅
useReffor mutable values that should not trigger renders, like:- DOM nodes (
inputRef.current.focus()) - IDs of timers or animation handles
- Previous values, measurement data, etc.
- DOM nodes (
Interview phrasing you might hear:
“
useStateis for reactive data tied to rendering.useRefis for persistent, mutable containers that survive renders without causing new ones.”
2. Why useEffect Returns a Cleanup Function (Memory Leaks 101)
Correct mental model: the cleanup of a
useEffectexists primarily to prevent leaks and stop side effects when the component unmounts or before the effect reruns.
Classic example with setInterval:
import { useEffect } from "react";
export function TickingClock() {
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
// 🔥 This function is the cleanup:
// - runs before the effect runs again
// - runs when the component unmounts
return () => clearInterval(id);
}, []);
return <p>Open the console and watch the ticks…</p>;
}
Without that cleanup, you get:
- Timers that keep running after unmount
- Duplicate intervals if the effect reruns
- Subscriptions that never unsubscribe
- Subtle performance bugs and memory leaks
This applies to any external resource:
-
addEventListener/removeEventListener - WebSockets
- Observers (Intersection, Resize, Mutation)
- Manual subscriptions (RxJS, custom event emitters)
Rule of thumb:
“If you subscribe or start something in an effect, you must unsubscribe or stop it in the cleanup.”
3. Many Small useEffects vs One Giant Effect
Good practice: prefer multiple small, focused
useEffects over a single huge effect that does everything.
❌ Bad: One mega-effect doing four unrelated things
useEffect(() => {
fetchUser(); // data fetch
const id = setInterval(updateClock, 1000); // timer
if (counter > 10) setWarning(true); // state rule
window.addEventListener("scroll", onScroll); // DOM subscription
return () => {
clearInterval(id);
window.removeEventListener("scroll", onScroll);
};
}, [counter]);
Problems:
- Mixed responsibilities (data, timer, rules, DOM).
- Confusing dependency array — what really controls this effect?
- Hard to test, reason about, or safely refactor.
✅ Good: Separate effects by responsibility
// 1) Fetch user once
useEffect(() => {
fetchUser();
}, []);
// 2) Clock timer
useEffect(() => {
const id = setInterval(updateClock, 1000);
return () => clearInterval(id);
}, []);
// 3) Counter rule
useEffect(() => {
if (counter > 10) setWarning(true);
}, [counter]);
// 4) Scroll listener
useEffect(() => {
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
Each effect now has:
- A single responsibility
- A clear lifecycle
- A minimal dependency array
Interview soundbite:
“
useEffectshould be treated like functions: small, specialized, and focused. One concern per effect.”
4. Typing useState in TypeScript — Why Generics Matter
Pattern:
useState<TrafficLightColor>("red")is safer than plainuseState("red").
If you don’t specify a type, TypeScript infers the most generic one:
const [color, setColor] = useState("red");
// color: string
Now any string is allowed:
setColor("red"); // OK
setColor("yellow"); // OK
setColor("purple-dragon"); // Also OK 😬
Better: restrict the possible values with a union type.
type TrafficLightColor = "red" | "yellow" | "green";
const [color, setColor] = useState<TrafficLightColor>("red");
Now TypeScript will reject invalid values and give you autocomplete:
setColor("blue");
// ❌ Type '"blue"' is not assignable to type '"red" | "yellow" | "green"'.
Why this matters in real code:
- Prevents invalid states before they exist.
- Documents intent right next to the state.
- Makes refactors safer (the compiler shows all breakages).
Interview phrasing:
“I like to use explicit generics with
useStatewhen the state is a constrained domain — it gives me autocomplete and prevents illegal values.”
5. Why useEffect(async () => { … }) Is a Smell
Core rule: the callback passed to
useEffectmust return either nothing or a cleanup function, not a Promise.
An async function always returns a Promise, so this breaks the contract:
// ❌ Anti-pattern
useEffect(async () => {
const data = await fetchSomething();
setState(data);
}, []);
React will treat the returned Promise as if it were a cleanup function, which:
- Is not callable
- Doesn’t clean anything up
- Can cause confusing timing and potential leaks
✅ Correct pattern: define an inner async function
useEffect(() => {
let cancelled = false;
async function load() {
try {
const data = await fetchSomething();
if (!cancelled) {
setState(data);
}
} catch (e) {
if (!cancelled) {
setError(e);
}
}
}
load();
return () => {
cancelled = true;
};
}, []);
This keeps the effect callback itself synchronous, while still allowing async/await inside.
Interview phrasing:
“
useEffectexpects an optional cleanup function, not a Promise. That’s why I never mark the effect callbackasync; I wrap async logic in an inner function instead.”
6. Components as the “Orchestrators” Between Hooks
Hooks don’t talk to each other directly — components wire them together.
Imagine a PokemonPage using both useCounter and usePokemon.
import { useCounter } from "./useCounter";
import { usePokemon } from "./usePokemon";
export function PokemonPage() {
const { value: id, inc, dec } = useCounter(1);
const { status, data } = usePokemon(id);
return (
<section>
<button onClick={dec}>Previous</button>
<button onClick={inc}>Next</button>
{status === "loading" && <p>Loading…</p>}
{status === "error" && <p>Not found</p>}
{status === "success" && data && (
<article>
<h2>#{id} — {data.name}</h2>
<img src={data.sprites.front_default} alt={data.name} />
</article>
)}
</section>
);
}
Here’s what’s really happening:
-
useCounter()returns theidvalue. - The component passes that
idintousePokemon(id). - On each render, React re-runs both hooks in order, with the latest props/state.
There is no secret hook‑to‑hook channel. The component is the mediator.
Interview phrasing:
“Hooks are just functions. They don’t communicate directly; the component calls them and threads values between them.”
7. Why useEffect Runs Twice in Development (StrictMode)
Trick question: “If a
useEffectlogs twice in development, does that mean it will run twice in production?”
Answer: No — in development,React.StrictModeintentionally double‑invokes effects to reveal unsafe logic.
Example:
useEffect(() => {
console.log("Effect executed");
}, []);
In development + StrictMode you’ll see:
Effect executed
Effect executed
In production builds you’ll see it only once.
Why double‑invocation helps:
- Surfaces effects that aren’t idempotent.
- Highlights missing cleanups.
- Prepares your code for concurrent features.
Rule of thumb:
“If an effect misbehaves when run twice in a row, it’s probably not robust enough.”
8. Custom Hooks and “Computed Properties”
Best practice: custom hooks shouldn’t only expose raw state — they should expose derived or formatted values too.
Bad version — pushing logic into every component:
// ❌ Hook returns raw data
function useUpload() {
const [loaded, setLoaded] = useState(0);
const [total, setTotal] = useState(0);
return { loaded, total, setLoaded, setTotal };
}
// Component now has to compute:
const { loaded, total } = useUpload();
const percentage = Math.round((loaded / total) * 100);
Better version — hook exposes a small, declarative API:
// ✅ Encapsulate the calculation
function useUpload() {
const [loaded, setLoaded] = useState(0);
const [total, setTotal] = useState(0);
const percentage =
total === 0 ? 0 : Math.round((loaded / total) * 100);
const formattedId = `UPL-${loaded}-${total}`;
return {
loaded,
total,
percentage,
formattedId,
setLoaded,
setTotal,
};
}
export function UploadProgress() {
const { percentage } = useUpload();
return <p>{percentage}% completed</p>;
}
Benefits:
- Components stay focused on presentation, not math.
- Logic is centralized and consistent.
- You avoid repeating the same calculation in multiple places.
Interview phrasing:
“A good custom hook exposes an API that’s already tailored for the UI — including computed properties — so components stay declarative.”
9. Mini Hooks Interview Checklist
Here’s a quick checklist you can review before your next interview or code review:
-
useStatevsuseRef-
useState→ triggers re-renders, for reactive UI state. -
useRef→ persists values across renders without re-rendering; great for DOM nodes and mutable containers.
-
-
useEffect- Always think: What do I need to clean up?
- Prefer multiple small effects over one giant effect.
- Never mark the effect callback
async; wrap async logic inside.
-
TypeScript + Hooks
- Use explicit union types with
useState<T>()for constrained domains. - Let the compiler prevent invalid states (great interview talking point).
- Use explicit union types with
-
Custom Hooks
- Extract logic (state + effects) out of components.
- Expose minimal, high‑level APIs and computed properties.
- Hooks don’t talk directly — the component orchestrates them.
-
StrictMode
- Double effects in dev are expected, not a bug.
- Use them as a signal to make your effects idempotent and clean.
Final Thoughts
Most “trick” questions about React Hooks are really questions about mental models:
- What actually causes a re-render?
- What owns the lifecycle of a side effect?
- Who coordinates communication between pieces of stateful logic?
- Where should logic live so components stay clean?
If you can answer those in your own words — and back them up with patterns like the ones above — you’re already operating at a senior React level.
Feel free to reuse these examples in your own slides, dev.to posts, or internal workshops.
✍️ Written by Cristian Sifuentes — building resilient front‑ends and teaching teams how to reason about async UI with React Hooks.

Top comments (0)