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 StrategiesCore JSType SystemReact FundamentalsFunctionsMicrofrontendsGenericsAsync JSObjectsMonorepoArrays'this' KeywordUtility TypesError HandlingModern JSBundle OptimizationPerformanceDOM & EventsState ManagementClasses & OOPCaching StrategiesComponent PatternsAdvanced TypesAuthenticationReact RouterFormsAdvanced PatternsFrontend SecurityConcurrent ReactServer ComponentsTestingEcosystemNetwork OptimizationCore Web VitalsBrowser APIs

Ready to practice Hooks?

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

Start Free Practice →