React · Hooks

Hooks Interview Questions
With Answers & Code Examples

13 carefully curated Hooks interview questions with working code examples and real interview gotchas.

Practice Interactively →← All Categories
13 questions3 beginner6 core4 advanced
Q1Beginner

What are the Rules of Hooks and why do they exist?

💡 Hint: Only call at top level, only in React functions — React relies on call order to track state

Two rules enforced by the eslint-plugin-react-hooks linter:

  1. Only call hooks at the top level — never inside loops, conditions, or nested functions
  2. Only call hooks in React function components or custom hooks — not in regular JS functions

Why? React tracks hook state by call order. It uses an internal linked list — hook #1, hook #2, etc. If you call hooks conditionally, the order changes between renders and React's state gets mismatched:

// ❌ WRONG — conditional hook breaks the call order
function Profile({ userId }) {
  if (!userId) return null; // return before hook!
  const [user, setUser] = useState(null); // hook #1 sometimes skipped
}

// ✅ CORRECT — always call hooks, handle condition inside
function Profile({ userId }) {
  const [user, setUser] = useState(null); // hook #1 always
  if (!userId) return null;               // condition after hooks
}
💡 If you find yourself wanting a conditional hook, extract the conditional logic into a custom hook where you can return early after the hooks are already called.
Practice this question →
Q2Beginner

Explain useState — batching, functional updates, and lazy initialization.

💡 Hint: State updates are batched; use functional form for updates based on previous state; pass a function to useState for expensive initial computation
const [state, setState] = useState(initialValue);

Functional updates — use when next state depends on current state:

// ❌ Potentially stale closure
setCount(count + 1);
setCount(count + 1); // both read the same stale 'count'

// ✅ Always gets the latest value
setCount(c => c + 1);
setCount(c => c + 1); // correctly increments twice

Batching — React 18 batches all state updates (even in setTimeout, fetch callbacks) into a single re-render:

// React 18: both updates batched into ONE re-render
setTimeout(() => {
  setCount(c => c + 1);
  setName('Alice');
}, 0);

Lazy initialization — pass a function to avoid re-running expensive setup on every render:

// ❌ computeExpensive() runs every render (result discarded after mount)
const [state, setState] = useState(computeExpensive());

// ✅ Only runs on mount
const [state, setState] = useState(() => computeExpensive());
💡 State updates are asynchronous — you won't see the new value immediately after calling setState. React schedules a re-render; the new value appears in the next render's closure.
Practice this question →
Q3Beginner

How does useEffect work? What are its dependency array behaviors?

💡 Hint: No array = every render; [] = mount only; [dep] = when dep changes; cleanup = before next effect + unmount
useEffect(() => {
  // effect logic
  return () => { /* cleanup */ };
}, [dependencies]);

Three dependency modes:

// 1. No array — runs after EVERY render
useEffect(() => { document.title = 'Re-ran'; });

// 2. Empty array — runs ONCE after mount
useEffect(() => {
  const ws = new WebSocket(url);
  return () => ws.close(); // cleanup on unmount
}, []);

// 3. With deps — runs when any dep changes
useEffect(() => {
  fetchUser(userId); // re-fetches whenever userId changes
}, [userId]);

Cleanup timing:

  • Runs before the next effect fires (when deps change)
  • Runs on component unmount
useEffect(() => {
  const id = setInterval(() => tick(), 1000);
  return () => clearInterval(id); // cleans up before next effect
}, [tick]);
💡 Missing deps are a common bug source. The linter rule exhaustive-deps catches these. If adding a dep causes an infinite loop, you likely need useCallback or to restructure the effect.
Practice this question →
Q4Core

What is the difference between useEffect and useLayoutEffect?

💡 Hint: useEffect fires after paint; useLayoutEffect fires synchronously before paint — use for DOM measurements

Both run after render but at different times:

  • useEffect — fires after the browser paints. Non-blocking. Use for most side effects (data fetching, subscriptions).
  • useLayoutEffect — fires synchronously after DOM mutations but before paint. Blocking. Use for reading layout to prevent visual flicker.
// useEffect — paint happens first, then effect
// Causes flicker if you update DOM based on measurement
useEffect(() => {
  const height = ref.current.offsetHeight; // reads layout
  setHeight(height); // triggers second render → visible flicker!
}, []);

// useLayoutEffect — DOM updated, effect runs, THEN paint
// No flicker because browser hasn't painted yet
useLayoutEffect(() => {
  const height = ref.current.offsetHeight;
  setHeight(height); // only one paint with correct height
}, []);

Sequence:

render → DOM mutations → useLayoutEffect → browser paint → useEffect
💡 Prefer useEffect by default — it doesn't block the browser. Only switch to useLayoutEffect if you see visual flickers from DOM measurement/mutation. Also note: useLayoutEffect is skipped during SSR.
Practice this question →
Q5Core

What is useRef and what are its two main use cases?

💡 Hint: Mutable box (.current) that persists across renders without causing re-renders — DOM access and storing mutable values

useRef returns a mutable object { current: initialValue }. Changing .current does NOT trigger a re-render.

Use case 1: DOM access

function TextInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // direct DOM access
  }, []);

  return ;
}

Use case 2: Storing mutable values (instance variables)

function Timer() {
  const intervalRef = useRef(null);
  const renderCount = useRef(0);

  renderCount.current++; // doesn't trigger re-render

  function start() {
    intervalRef.current = setInterval(tick, 1000);
  }
  function stop() {
    clearInterval(intervalRef.current); // access latest value, no stale closure
  }
}

useRef vs useState for mutable values:

  • Need re-render when it changes? → useState
  • Just need to store/read without re-rendering? → useRef
💡 useRef solves the "stale closure" problem in event handlers or setInterval. Since .current is a stable object reference, you always read the latest value even from old closures.
Practice this question →
Q6Core

What is useContext and when should you avoid it?

💡 Hint: Subscribe to context value — every consumer re-renders when context value changes
// 1. Create context
const ThemeContext = createContext('light');

// 2. Provide value
function App() {
  const [theme, setTheme] = useState('light');
  return (
    
      
    
  );
}

// 3. Consume anywhere in the tree
function Button() {
  const { theme, setTheme } = useContext(ThemeContext);
  return ;
}

When to avoid:

  • High-frequency updates — every consumer re-renders on every context change. Putting frequently-changing state (mouse position, scroll) in context is expensive.
  • Unrelated values in one context — split into separate contexts so consumers only re-render for changes they care about.
// ❌ One fat context — Button re-renders when user changes even if only needs theme
const AppContext = createContext({ theme, user, cart });

// ✅ Split contexts — each consumer only subscribes to what it needs
const ThemeContext = createContext(theme);
const UserContext  = createContext(user);
💡 Context is not a replacement for state management — it's a way to avoid prop drilling. For high-frequency global state, use Zustand or Jotai which use subscription-based updates.
Practice this question →
Q7Core

What is useReducer and when should you use it over useState?

💡 Hint: Complex state logic with multiple sub-values or when next state depends on action type — like Redux at component level
const [state, dispatch] = useReducer(reducer, initialState);
const initialState = { count: 0, loading: false, error: null };

function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':   return { ...state, count: state.count + 1 };
    case 'SET_LOADING': return { ...state, loading: action.payload };
    case 'SET_ERROR':   return { ...state, error: action.payload };
    case 'RESET':       return initialState;
    default:            return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    
  );
}

Choose useReducer when:

  • Multiple related state values that change together
  • Next state depends on action type, not just a single value
  • Complex state transition logic you want to test independently
  • State transitions need to be auditable (dispatch history)
💡 useReducer + useContext is a lightweight Redux alternative. Put dispatch in context — it's stable (never changes) so context consumers won't re-render unnecessarily when state changes.
Practice this question →
Q8Core

What is useCallback and when does it actually help?

💡 Hint: Memoizes a function reference — only helps when the function is a dep of useEffect or a prop to React.memo children

useCallback(fn, deps) returns a memoized function that only changes if deps change.

const handleClick = useCallback(() => {
  doSomething(id);
}, [id]); // only creates a new function when id changes

It only helps in two scenarios:

1. Passed to a React.memo child (prevents unnecessary re-renders):

const MemoChild = React.memo(({ onClick }) => );

// Without useCallback — new function ref every render → MemoChild always re-renders
// With useCallback — same ref if deps unchanged → MemoChild skips re-render
const handleClick = useCallback(() => doWork(id), [id]);

2. Used as a useEffect dependency:

const fetchData = useCallback(() => {
  api.get(url).then(setData);
}, [url]);

useEffect(() => { fetchData(); }, [fetchData]); // won't re-run unless url changes

When it does NOT help:

  • Functions passed to native DOM elements (div, button) — they don't check reference equality
  • Without React.memo on the child — the child re-renders regardless
💡 useCallback has a cost — it runs on every render to check deps. Don't add it everywhere "just in case". Profile first, optimize second.
Practice this question →
Q9Core

What is useMemo and how is it different from useCallback?

💡 Hint: useMemo caches a computed VALUE; useCallback caches a FUNCTION reference — both memoize based on deps
// useMemo — memoizes the RETURN VALUE of a function
const sortedList = useMemo(() => {
  return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]); // only re-sorts when items changes

// useCallback — memoizes the FUNCTION ITSELF
const handleSort = useCallback(() => {
  setSortedList([...items].sort(...));
}, [items]);

Use useMemo for:

  • Expensive computations (filtering 10k items, building complex data structures)
  • Stable object/array references passed as props to React.memo children
  • Values used as useEffect dependencies that would otherwise change every render
// ❌ New object every render → useEffect re-runs every render
const options = { timeout: 5000, retries: 3 };
useEffect(() => fetchData(options), [options]);

// ✅ Stable reference
const options = useMemo(() => ({ timeout: 5000, retries: 3 }), []);
useEffect(() => fetchData(options), [options]);
💡 useMemo(fn, deps) is equivalent to useCallback(fn, deps) when you think about it: useCallback(fn, deps) = useMemo(() => fn, deps). They're the same mechanism, different sugar.
Practice this question →
Q10Advanced

What are custom hooks and what are the patterns for building them?

💡 Hint: Functions starting with "use" that call other hooks — extract reusable stateful logic

Custom hooks extract stateful logic from components into reusable functions. They must start with use.

// Custom hook — useFetch
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(d => { if (!cancelled) setData(d); })
      .catch(e => { if (!cancelled) setError(e); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; }; // cleanup race condition
  }, [url]);

  return { data, loading, error };
}

// Usage — clean component
function UserProfile({ id }) {
  const { data: user, loading } = useFetch(`/api/users/${id}`);
  if (loading) return ;
  return 

{user.name}

; }

Common custom hook patterns:

  • useLocalStorage(key, initial) — sync state with localStorage
  • useDebounce(value, delay) — debounce a value
  • useOnClickOutside(ref, handler) — close dropdowns
  • usePrevious(value) — track previous render's value
  • useIntersectionObserver(ref) — lazy loading / infinite scroll
💡 Custom hooks don't share STATE — they share LOGIC. Each component that calls a custom hook gets its own isolated state. Think of them as reusable useState + useEffect combos.
Practice this question →
Q11Advanced

What is useId and when do you need it?

💡 Hint: Generates stable unique IDs that are consistent between server and client — for accessibility attributes

useId generates a stable, unique ID that is consistent across server-side rendering and client-side hydration.

// ❌ Problem — random or Math.random IDs cause SSR hydration mismatch
function Field({ label }) {
  const id = Math.random(); // different value on server vs client → hydration error!
  return (
    <>
      
      
    
  );
}

// ✅ useId — stable, unique, SSR-safe
function Field({ label }) {
  const id = useId(); // e.g. ":r0:", ":r1:" — consistent server + client
  return (
    <>
      
      
    
  );
}

// Multiple IDs from one useId call — append suffixes
function ComboField() {
  const id = useId();
  return (
    <>
      
      
      

Your full name

); }
💡 useId is purely for accessibility attributes (id, htmlFor, aria-*). Don't use it as a key prop in lists — use your data's actual IDs for those.
Practice this question →
Q12Advanced

What are useImperativeHandle and forwardRef?

💡 Hint: forwardRef passes ref through to a child; useImperativeHandle customizes what the parent sees on that ref

forwardRef — lets a parent component pass a ref to a DOM node or component instance inside a child.

useImperativeHandle — customizes what a parent receives via that ref, exposing only a controlled API instead of the raw DOM node.

// Without forwardRef — parent can't reach inner input
function Input(props) {
  return ;
}

// ✅ With forwardRef — parent can call .focus(), etc.
const Input = forwardRef(function Input(props, ref) {
  return ;
});

// Parent
function Form() {
  const inputRef = useRef(null);
  return (
    <>
      
      
    
  );
}

// ─── useImperativeHandle — expose limited API ──────────────────────────────
const FancyInput = forwardRef(function FancyInput(props, ref) {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    clear: () => { inputRef.current.value = ''; },
    // Parent can NOT access inputRef.current directly — only these two methods
  }));

  return ;
});

const ref = useRef();
ref.current.focus(); // ✅
ref.current.value;   // ❌ undefined — not exposed
💡 Use forwardRef sparingly — it tightly couples parent to child internals. Most of the time you can solve the problem with a controlled component pattern instead. Use it mainly for UI library components (inputs, modals).
Practice this question →
Q13Advanced

What is the stale closure problem in React hooks and how do you fix it?

💡 Hint: A closure captures variables at creation time — if state changes but the closure isn't recreated, it reads old values

A stale closure occurs when a function captures a value from a previous render and never gets updated to see the new value.

// ❌ Classic example — setInterval with stale count
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // always logs 0 — stale closure!
      setCount(count + 1); // always sets to 1 — bug!
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps — closure captures count=0 forever
}

// ✅ Fix 1 — functional update (doesn't need to close over count)
setCount(c => c + 1); // always gets the latest count

// ✅ Fix 2 — add count to deps (effect re-runs on every count change)
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id); // cleans up old interval
}, [count]);

// ✅ Fix 3 — useRef stores mutable value, not affected by closures
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);

useEffect(() => {
  const id = setInterval(() => {
    setCount(countRef.current + 1); // always reads latest
  }, 1000);
  return () => clearInterval(id);
}, []);
💡 The eslint-plugin-react-hooks exhaustive-deps rule catches most stale closure issues at lint time. When you're tempted to suppress it, think about whether a functional update or ref would solve the problem instead.
Practice this question →

Other React Interview Topics

Rendering Strategies →Core JS →Type System →React Fundamentals →Functions →Microfrontends →Generics →Async JS →Objects →Monorepo →Arrays →'this' Keyword →Utility Types →Error Handling →Modern JS →Bundle Optimization →Performance →DOM & Events →State Management →Classes & OOP →Caching Strategies →Component Patterns →Advanced Types →Authentication →React Router →Forms →Advanced Patterns →Frontend Security →Concurrent React →Server Components →Testing →Ecosystem →Network Optimization →Core Web Vitals →Browser APIs →

Ready to practice Hooks?

Get AI feedback on your answers, predict code output, and fix real bugs.

Start Free Practice →