Episode 2 — React Frontend Architecture NextJS / 2.7 — Useful Hooks in React

Interview Questions: Useful Hooks in React

How to use this material:

  1. Read the question and formulate your answer before looking at the model answer
  2. Practice answering out loud — interviews are verbal, not written
  3. Focus on the "Why interviewers ask" section to understand the real skill being tested
  4. Model answers are comprehensive — in a real interview, hit the key points in 2-3 minutes
  5. Star questions you struggle with and revisit them before your interview

Beginner (Q1–Q6)

Q1. What are React hooks and why were they introduced?

Why interviewers ask: They want to know if you understand the motivation behind hooks, not just how to use them. This reveals whether you think about tools as solutions to problems or just as syntax.

Model answer:

Hooks are functions that let you use React features — state, effects, context, refs — inside function components. They were introduced in React 16.8 to solve three specific problems.

First, logic reuse was painful. Before hooks, sharing stateful logic between components required Higher-Order Components or Render Props, both of which created "wrapper hell" — deeply nested component trees that were hard to debug in DevTools and hard to follow in code.

Second, complex components were hard to understand. Class components grouped code by lifecycle method, not by concern. A chat subscription might be split across componentDidMount, componentDidUpdate, and componentWillUnmount. Related logic was scattered; unrelated logic was lumped together. Hooks let you organize by concern — one useEffect for the subscription, another for the timer.

Third, classes confused both people and machines. The this keyword in JavaScript is notoriously confusing — developers regularly forgot to bind methods. Classes also don't minify well and make hot reloading unreliable. Hooks eliminated these issues with plain functions and closures.

The key insight is that hooks aren't just syntactic sugar for classes — they're a fundamentally different model. Instead of thinking in terms of lifecycle ("when does this run?"), hooks encourage thinking in terms of synchronization ("what am I synchronizing with?").


Q2. Explain the Rules of Hooks. Why do they exist?

Why interviewers ask: This tests whether you understand React's internals or just follow rules blindly. Developers who understand the "why" make fewer bugs.

Model answer:

There are two rules. First, only call hooks at the top level — never inside conditions, loops, or nested functions. Second, only call hooks from React functions — either function components or custom hooks (functions starting with use).

These rules exist because of how React stores hook state internally. React uses a linked list indexed by call order. On the first render, it creates a slot for each hook in the order they're called. On subsequent renders, it walks through the same list, matching each hook call to its stored state by position.

If you put a hook inside a condition, the call order might change between renders. Say render 1 calls useState, useEffect, useState (positions 0, 1, 2). If the condition is false on render 2, it calls useState, useState (positions 0, 1). Now the second useState reads from position 1, which holds the useEffect's data. This causes silent state corruption.

The second rule exists because hooks need to attach their state to a component's Fiber node. Outside a component render, there's no Fiber node — React doesn't know where to store the data.

The official ESLint plugin eslint-plugin-react-hooks enforces both rules. The rules-of-hooks rule catches structural violations, and exhaustive-deps catches missing effect dependencies.


Q3. What's the difference between useState and useRef?

Why interviewers ask: This tests whether you understand React's re-rendering model. Knowing when to trigger a re-render vs. when to silently mutate is fundamental.

Model answer:

Both persist values across renders, but they differ in one critical way: useState triggers a re-render when updated; useRef does not.

useState returns [value, setter]. When you call the setter, React schedules a re-render, the component function runs again, and the UI updates to reflect the new state. Use it for anything the user needs to see change — counts, input values, loading flags, lists.

useRef returns { current: value }. You can mutate .current directly and React won't re-render. Use it for two things: DOM references (accessing actual DOM nodes for imperative operations like focusing) and internal mutable values that shouldn't affect rendering (timer IDs, previous values, render counts, latest callback references).

A common misconception is that useRef is only for DOM elements. It's actually a general-purpose "mutable box" — think of it as an instance variable that lives outside the render cycle.

The rule of thumb: if the value affects what the user sees, use useState. If you need to remember something without affecting the display, use useRef.


Q4. How does useEffect work? What are the three dependency array configurations?

Why interviewers ask: useEffect is the most commonly misunderstood hook. They want to verify you understand the synchronization model, not just the syntax.

Model answer:

useEffect lets you synchronize your component with an external system — the DOM, an API, a WebSocket, the browser title, localStorage. It runs after the browser paints, so it doesn't block the visual update.

There are three configurations based on the dependency array:

No array: useEffect(() => { ... }) — runs after every render. Rarely needed, but useful for debugging or analytics that must track all changes.

Empty array: useEffect(() => { ... }, []) — runs once after the first render. Use for one-time setup like event listeners or initial data fetches. The cleanup runs on unmount.

With dependencies: useEffect(() => { ... }, [a, b]) — runs when any dependency changes (compared with Object.is). This is the most common form. The cleanup from the previous run executes before the new effect runs.

The mental model shift from classes is important: don't think "componentDidMount runs once." Think "I'm synchronizing the document title with the current count." The dependency array tells React what you're synchronizing with — when those values change, the synchronization needs to re-run.

Cleanup is essential: always clean up subscriptions, timers, and abort pending fetches to prevent memory leaks and stale callbacks.


Q5. What is useContext and how does it relate to the Provider pattern?

Why interviewers ask: Context is how React handles tree-wide state. They want to know if you understand the Provider/Consumer pattern and its performance implications.

Model answer:

useContext reads the current value from the nearest Context Provider above in the component tree. It replaces the older Context.Consumer render prop pattern with a much simpler API.

The workflow has three steps: create a context with createContext(defaultValue), wrap a subtree with <MyContext.Provider value={...}>, and read the value in any descendant with useContext(MyContext).

The key behavior to understand is re-rendering: when the Provider's value changes, every component that calls useContext for that context re-renders. This has performance implications. If you put multiple unrelated values in one context, a change to any value re-renders all consumers — even those that only use a different part of the value.

The standard mitigation strategies are: split contexts by concern (separate UserContext and ThemeContext), memoize the Provider value with useMemo, and split read-only and dispatch contexts (dispatch functions are stable and don't cause re-renders).

A best practice is wrapping useContext in a custom hook that throws if the context is null — this catches the common mistake of using the hook outside its Provider.


Q6. When should you use useCallback vs useMemo?

Why interviewers ask: Premature optimization is a common React anti-pattern. They want to know if you optimize based on measurement or cargo-cult.

Model answer:

Both are memoization hooks, but they cache different things: useMemo caches a computed value, useCallback caches a function reference.

useMemo(() => expensiveCalculation(a, b), [a, b]) only recomputes when a or b change. Use it for expensive calculations (filtering 10,000 items), stabilizing object references passed to React.memo children, and memoizing context provider values.

useCallback(fn, [deps]) returns the same function reference unless dependencies change. It's syntactic sugar for useMemo(() => fn, [deps]). Use it when passing callbacks to React.memo children (otherwise the new function reference breaks memoization) or when the function is used as a dependency in another hook.

The critical point: neither is free. Both have overhead — they allocate memory for the cached value and run comparison logic on every render. For trivial computations (string concatenation, boolean checks), the overhead exceeds the savings.

My approach: write code without memoization first, profile with DevTools, and add useMemo/useCallback only where measurement shows a benefit. The React Compiler is also reducing the need for manual memoization.


Intermediate (Q7–Q12)

Q7. Explain the "stale closure" problem in hooks. How do you solve it?

Why interviewers ask: This is the #1 source of bugs in hooks code. Interviewers want to know if you've encountered it and understand the underlying mechanism.

Model answer:

In hooks, every render creates its own closure that captures the state and props values at that point in time. This is normally a feature — it makes behavior predictable. But it becomes a problem when a callback created during render N is executed much later, still seeing render N's values instead of the current ones.

Classic example: you click a button that sets a 3-second timeout alerting the count. If count is 0 when you click, the alert always shows 0 — even if you increment the count 10 times during those 3 seconds. The setTimeout captured count = 0 in its closure.

In useEffect, this manifests when you omit a dependency: the effect closes over an old value and never sees updates.

Three solutions:

  1. Use updater functions: setCount(prev => prev + 1) — the updater always receives the latest state.
  2. Add the value to the dependency array: Forces the effect to re-run when the value changes.
  3. Use a ref for the latest value: const countRef = useRef(count); countRef.current = count; — then read countRef.current in the callback. Refs always point to the latest value since they're mutable.

The ref pattern is especially useful for event handler callbacks in effects where you don't want the effect to re-subscribe every time the callback changes.


Q8. How would you handle data fetching in useEffect? What are the common pitfalls?

Why interviewers ask: Data fetching is the most common use of useEffect and the most common source of bugs. This tests practical React skills.

Model answer:

The basic pattern is: inside useEffect, start a fetch, update state on success, and return a cleanup function that cancels the request.

Three critical pitfalls to handle:

Race conditions: If userId changes rapidly (1 → 2 → 3), three fetches fire. If fetch for userId=1 returns after fetch for userId=3, you'd show stale data. The fix is AbortController: create one per effect, abort on cleanup. This ensures only the latest request's response is processed.

Memory leaks: If the component unmounts before the fetch completes, calling setState on an unmounted component was historically a warning. With AbortController, the abort signal prevents the .then from executing.

Missing loading/error states: Always track loading, error, and data states. The cleanest approach is a discriminated union with useReducer: { status: 'idle' | 'loading' | 'success' | 'error', data, error }. This makes impossible states unrepresentable.

That said, the modern recommendation is to avoid useEffect for data fetching in favor of dedicated libraries like TanStack Query or framework-level solutions like Next.js Server Components. These handle caching, deduplication, background refetching, and race conditions automatically. Use useEffect for fetching only in learning contexts or simple apps.


Q9. Context re-renders all consumers when the value changes. How do you optimize this?

Why interviewers ask: This tests advanced React performance knowledge. Many developers use context incorrectly and cause unnecessary re-renders.

Model answer:

When a Context Provider's value changes, every component calling useContext for that context re-renders — regardless of whether it uses the part of the value that changed. This can cause performance problems in large apps.

Four optimization strategies:

Split contexts: Instead of one AppContext with user, theme, and locale, create separate UserContext, ThemeContext, and LocaleContext. Theme changes only re-render theme consumers.

Memoize provider values: Wrap the value object in useMemo so the reference only changes when the actual data changes. Without this, every parent re-render creates a new object reference, triggering all consumers.

Split state and dispatch: Create separate contexts for state (changes frequently) and dispatch functions (stable reference). Components that only dispatch actions (like an "Add Todo" button) don't re-render when the todo list changes.

Selective consumption with external stores: For fine-grained subscriptions, use useSyncExternalStore or libraries like Zustand. These let components subscribe to specific slices of state, not the entire context value.

The key principle: minimize the number of components that subscribe to frequently-changing context values, and make sure the value reference is stable when the actual data hasn't changed.


Q10. What's the difference between useEffect and useLayoutEffect?

Why interviewers ask: This tests understanding of the rendering pipeline. Most developers never need useLayoutEffect, but knowing the difference shows depth.

Model answer:

Both run after render, but at different points in the browser's paint cycle.

useEffect runs after the browser paints. This means the user sees the updated DOM first, then the effect runs. It's non-blocking and doesn't delay visual updates. Use it for most side effects: data fetching, subscriptions, analytics.

useLayoutEffect runs before the browser paints. It fires synchronously between the DOM mutation and the screen update. Use it when you need to measure the DOM or make visual adjustments before the user sees the result — preventing a visual "flicker."

Concrete example: measuring an element's height to position a tooltip. With useEffect, the user briefly sees the tooltip in the wrong position, then it jumps. With useLayoutEffect, the measurement and repositioning happen before the paint, so the user never sees the intermediate state.

The tradeoff: useLayoutEffect blocks painting. If your effect is slow, it delays the visual update. That's why it should only be used for DOM measurements and visual adjustments — never for data fetching or heavy computation.

For SSR, useLayoutEffect fires a warning because there's no DOM on the server. The common solution is to use useEffect on the server and useLayoutEffect on the client, or to use a useIsomorphicLayoutEffect utility.


Q11. How does React batch state updates? Has this changed in React 18?

Why interviewers ask: Understanding batching is crucial for predicting when re-renders happen and writing performant code.

Model answer:

Batching means React groups multiple setState calls into a single re-render instead of re-rendering after each one.

Before React 18, batching only worked inside React event handlers. Updates inside setTimeout, fetch.then(), or native event listeners were NOT batched — each setState triggered a separate re-render.

React 18 introduced automatic batching everywhere. Now ALL state updates are batched, regardless of where they happen — event handlers, timeouts, promises, native events. This was a significant performance improvement.

If you call setCount(c + 1), setName('Bob'), and setLoading(false) in the same function, React queues all three and performs one re-render with all three updates applied.

One important nuance: if you need to read the updated DOM immediately after a state change (rare), you can use flushSync from react-dom to force synchronous updating. But this should be a last resort — it defeats batching's performance benefits.

The practical implication: when using updater functions setCount(prev => prev + 1), multiple calls chain correctly because each updater receives the result of the previous one. With direct values setCount(count + 1), multiple calls in the same handler all read the same stale count from the closure.


Q12. Explain the useReducer hook. When would you choose it over useState?

Why interviewers ask: useReducer reveals understanding of state management patterns. Developers who can articulate when to use each demonstrate architectural thinking.

Model answer:

useReducer accepts a reducer function and initial state, returning the current state and a dispatch function. The reducer takes the current state and an action, returning the new state. It's similar to Redux's pattern but scoped to a single component.

I reach for useReducer over useState in four situations:

Complex state objects: When state has multiple sub-values that update together. With useState, you'd need multiple calls and risk forgetting to update one. A reducer ensures all related changes happen atomically.

State transitions: When the next state depends on the current state in complex ways. A reducer with named actions ('FETCH_START', 'FETCH_SUCCESS', 'FETCH_ERROR') makes transitions explicit and self-documenting.

Testable logic: The reducer is a pure function — you can test it independently without rendering components. Pass in state and action, assert on output. This is especially valuable for complex business logic.

Avoiding impossible states: With separate useState for loading, error, and data, you can accidentally have loading: true and error: "failed" simultaneously. A discriminated union in a reducer (status: 'loading' | 'error' | 'success') makes invalid combinations impossible.

The rule of thumb: use useState for independent primitive values (a toggle, a counter, a text input). Switch to useReducer when you have 3+ related state values, when update logic is complex, or when you want the self-documenting nature of action types.


Advanced (Q13–Q18)

Q13. Walk through how React processes hooks internally. What happens during mount vs update?

Why interviewers ask: This tests deep React internals knowledge. Senior developers should understand the mechanism, not just the API.

Model answer:

React maintains a Fiber tree where each component node stores its hooks as a linked list. The processing differs between mount (first render) and update (re-renders).

During mount: React calls your component function. Each hook call creates a new hook object and appends it to the Fiber's hook linked list. useState stores the initial value and creates an update queue. useEffect stores the effect function and dependency array, marking it for execution after commit. useRef creates the { current: initialValue } object.

During update: React resets an internal index to 0. Each hook call reads from the next position in the existing linked list. useState processes any queued updates (from setState calls) using the reducer pattern internally (even direct values go through a simple "replace" reducer). useEffect compares the new dependency array with the stored one using Object.is — if any value changed, it marks the effect for re-execution. useRef simply returns the same object (it never changes).

The critical design decision is positional identification. React doesn't use hook names or keys — it relies purely on call order. This is why the Rules of Hooks exist: if call order changes between renders, position N maps to the wrong hook, causing state corruption.

After the render phase, React enters the commit phase where DOM mutations happen. Then, useLayoutEffect runs synchronously, the browser paints, and finally useEffect runs asynchronously.

The update queue for useState uses a priority system in React 18+ — transitions are processed at lower priority than user events, enabling concurrent features.


Q14. Design a custom hook useAsync that handles loading, error, and data states for any async operation. What considerations are important?

Why interviewers ask: This tests the ability to design reusable abstractions. Good hook design requires understanding composition, cleanup, and edge cases.

Model answer:

function useAsync(asyncFn, dependencies) {
  const [state, dispatch] = useReducer(asyncReducer, {
    status: 'idle', data: null, error: null
  });
  
  useEffect(() => {
    if (!asyncFn) return;
    
    let cancelled = false;
    dispatch({ type: 'PENDING' });
    
    asyncFn()
      .then(data => { if (!cancelled) dispatch({ type: 'FULFILLED', data }); })
      .catch(error => { if (!cancelled) dispatch({ type: 'REJECTED', error }); });
    
    return () => { cancelled = true; };
  }, dependencies);
  
  return state;
}

Key considerations:

Race condition prevention: The cancelled flag prevents setting state from stale requests. Even better would be AbortController integration, but that requires the consumer to pass a signal-aware function.

Discriminated union state: Using a reducer with status field prevents impossible states like { loading: true, error: "something" }. Each dispatch transition is atomic.

Stable dispatch: useReducer's dispatch is stable across renders, so it never causes unnecessary effect re-runs.

Dependency management: The dependencies parameter lets consumers control when the async operation re-runs. This follows the same pattern as useEffect.

Immediate execution vs lazy: This hook auto-executes. An alternative design returns an execute function for on-demand triggering (useful for mutations). A production hook would support both patterns.

Cleanup: If the component unmounts mid-request, the cancelled flag prevents setState on unmounted components. For AbortController support, you'd accept an options object with a signal.

In production, I'd use TanStack Query instead — it handles all these cases plus caching, deduplication, background refetching, and pagination. But understanding these fundamentals is essential.


Q15. How would you prevent Context from causing unnecessary re-renders in a large application?

Why interviewers ask: This tests performance optimization skills at scale. Context re-rendering is one of the most common React performance problems.

Model answer:

The fundamental problem: when a Context Provider's value changes, every useContext consumer re-renders, even if they only use a slice of the value that didn't change.

Strategy 1: Context splitting. Separate frequently-changing state from rarely-changing state. Instead of one AppContext, create UserContext (changes on login/logout), ThemeContext (changes on toggle), LocaleContext (almost never changes). Each consumer only subscribes to what it needs.

Strategy 2: State/dispatch separation. Create two contexts: one for state (changes often) and one for dispatch functions (stable references). Components that only trigger actions (like buttons) consume only the dispatch context and never re-render due to state changes.

Strategy 3: Memoize provider values. Always wrap the value object in useMemo. Without this, every parent re-render creates a new object reference, even if the actual data is identical, triggering all consumers unnecessarily.

Strategy 4: Composition over context. Sometimes the real fix is restructuring components so data flows through props or children, avoiding context entirely. The compound component pattern and slot pattern can eliminate the need for context in many cases.

Strategy 5: External stores. For high-frequency updates (real-time data, animations), use useSyncExternalStore or Zustand. These support selector patterns where components subscribe to specific state slices: const count = useStore(state => state.count). Only components whose selected slice changed re-render.

The key principle: context is for data that many components need and that changes infrequently. For high-frequency state, use external stores with selectors.


Q16. Explain the closure model in React hooks vs the mutable this model in classes. Which is better and why?

Why interviewers ask: This tests conceptual understanding of React's execution model. Senior engineers should articulate the tradeoffs, not just preferences.

Model answer:

In class components, this.state and this.props are mutable references that always point to the latest values. Reading this.state.count in a setTimeout always gives the current count, even if it changed since the timeout was set.

In hooks, each render creates a closure that captures the values at render time. A setTimeout set during render N will always see render N's values. If count was 5 when the timeout was set, it reads 5 — even if count is now 10.

The class model feels intuitive for people coming from OOP: objects have mutable properties. But it causes subtle bugs. If you render a profile page, the user clicks "follow," and then navigates to a different profile before the network request completes, the class model might follow the wrong user — because this.props.userId now points to the new profile.

The hooks closure model prevents this: the effect captured the userId from the render where the user clicked follow. Even if props change, the original userId is preserved in the closure.

The tradeoff: closures can cause stale data when you actually WANT the latest value. The solution is useRef — store the latest value in a ref and read it when needed: ref.current = latestValue.

Neither model is objectively "better" — they have different default behaviors. The closure model is safer by default (values are always consistent within a render), while the mutable model requires explicit snapshot-taking for consistency. React chose the safer default.


Q17. A component re-renders 50 times per second. How do you diagnose and fix the performance problem using hooks?

Why interviewers ask: This tests real-world debugging skills. Performance optimization requires systematic diagnosis, not guesswork.

Model answer:

Step 1: Diagnose with DevTools. Open React DevTools Profiler, enable "Highlight updates when components render," and identify which components re-render and why. The Profiler shows render timing and what triggered each render (state change, props change, parent re-render, context change).

Step 2: Identify the root cause. Common culprits:

  • Inline object/array/function in JSX: <Child style={{ color: 'red' }}> creates a new object every render. Fix: hoist to a constant, use useMemo, or use CSS classes.

  • Context value not memoized: Provider creates new object reference every render. Fix: wrap in useMemo.

  • Parent re-renders cascade to children: Even if props haven't changed, children re-render by default. Fix: React.memo on expensive children, combined with useCallback/useMemo for their props.

  • Effect causing re-render loop: useEffect that sets state → re-render → effect runs again → sets state. Fix: add proper dependency array, use state updater functions.

  • External subscription without proper cleanup: Window resize/scroll handlers firing setState continuously. Fix: debounce/throttle the handler.

Step 3: Apply targeted fixes. Don't optimize everything — focus on the expensive renders. Use useMemo for expensive computations, useCallback for callbacks passed to memoized children, React.memo for pure children, useTransition for non-urgent updates, and useDeferredValue to show stale content while computing.

Step 4: Structural fixes. Sometimes the real problem is component architecture — state too high in the tree causing massive cascading re-renders. Move state closer to where it's used, use composition to avoid intermediate re-renders, or use external stores with selectors.

Always measure before and after. DevTools Profiler quantifies the improvement.


Q18. React 19 introduces new hooks like use(), useFormStatus, useActionState, and useOptimistic. How do they change React development?

Why interviewers ask: This tests awareness of the React ecosystem direction. Senior developers should understand where the framework is heading.

Model answer:

React 19's hooks signal a shift toward server-first architecture and progressive enhancement.

use(): A special hook that can read promises and context conditionally — breaking the "only call at top level" rule. You can use(somePromise) inside a condition, and React handles the suspension. This simplifies data fetching dramatically: components can directly await data during render without useEffect. Combined with Server Components, it enables streaming SSR with suspense boundaries.

useActionState (formerly useFormState): Manages state that results from form submissions via Server Actions. It takes an action function and initial state, returning current state and a bound action. The key benefit: forms work without JavaScript — the action runs on the server with progressive enhancement. When JS loads, it enhances with client-side interactivity.

useFormStatus: Reads the status of a parent <form> — specifically pending, data, method, and action. This lets you build submit buttons that disable themselves during submission without prop drilling or context.

useOptimistic: Shows an optimistic UI immediately while an async operation is pending. You provide the current state and an update function, and it returns the optimistic state that automatically reverts if the operation fails.

The broader pattern: React is moving form handling, data fetching, and mutation logic from useEffect + client state into framework-level primitives that work with both server and client rendering. The mental model shifts from "fetch in useEffect, update state" to "declare data dependencies, let the framework handle the rest."

These hooks don't replace existing ones — useState and useEffect remain essential. But they provide higher-level abstractions for common patterns that previously required significant boilerplate.


Practice these until you can answer confidently in 2-3 minutes each. Focus on the "why" — interviewers value understanding over memorization.