DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

React Hooks Interview Mastery — From Quiz Answers to Senior-Level Patterns

React Hooks Interview Mastery — From Quiz Answers to Senior‑Level Patterns

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 useRef instead of useState?
  • Why does useEffect return a cleanup function?
  • Is it better to have one big useEffect or 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 useState re-renders the component. Updating useRef.current does 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to use which:

  • useState for reactive state — anything that should show up in the UI, or that drives component logic.
  • useRef for mutable values that should not trigger renders, like:
    • DOM nodes (inputRef.current.focus())
    • IDs of timers or animation handles
    • Previous values, measurement data, etc.

Interview phrasing you might hear:

useState is for reactive data tied to rendering. useRef is 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 useEffect exists 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>;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

Each effect now has:

  • A single responsibility
  • A clear lifecycle
  • A minimal dependency array

Interview soundbite:

useEffect should 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 plain useState("red").

If you don’t specify a type, TypeScript infers the most generic one:

const [color, setColor] = useState("red");
// color: string
Enter fullscreen mode Exit fullscreen mode

Now any string is allowed:

setColor("red");          // OK
setColor("yellow");       // OK
setColor("purple-dragon"); // Also OK 😬
Enter fullscreen mode Exit fullscreen mode

Better: restrict the possible values with a union type.

type TrafficLightColor = "red" | "yellow" | "green";

const [color, setColor] = useState<TrafficLightColor>("red");
Enter fullscreen mode Exit fullscreen mode

Now TypeScript will reject invalid values and give you autocomplete:

setColor("blue");
// ❌ Type '"blue"' is not assignable to type '"red" | "yellow" | "green"'.
Enter fullscreen mode Exit fullscreen mode

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 useState when 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 useEffect must 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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;
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

This keeps the effect callback itself synchronous, while still allowing async/await inside.

Interview phrasing:

useEffect expects an optional cleanup function, not a Promise. That’s why I never mark the effect callback async; 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here’s what’s really happening:

  1. useCounter() returns the id value.
  2. The component passes that id into usePokemon(id).
  3. 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 useEffect logs twice in development, does that mean it will run twice in production?”

Answer: No — in development, React.StrictMode intentionally double‑invokes effects to reveal unsafe logic.

Example:

useEffect(() => {
  console.log("Effect executed");
}, []);
Enter fullscreen mode Exit fullscreen mode

In development + StrictMode you’ll see:

Effect executed
Effect executed
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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:

  • useState vs useRef

    • 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).
  • 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)