React Hooks for Interviews

Master React hooks for interviews, covering key hooks like useState, useEffect, useContext, common pitfalls, and best practices for building reusable logic

作者
Ex-Meta Staff Engineer

React hooks are special functions that start with use. Introduced in React 16.8, they allow developers to use state and lifecycle features in function components. This simplifies code organization, reduce boilerplate, and enable better logic reuse. This guide will help you to tackle React hooks interview questions.

Familiarize yourself with the common hooks: useState, useEffect, useContext, useRef, useId, you will probably have to use some of them during interviews.

useState

useState enables function components to track state within component instances. State is isolated and private. Use useState when a component needs local state that is contained within the component instance and not meant to be shared with siblings/parents. If you render a component in two separate places, each instance gets its own state.

const [state, setState] = useState(initialState);

Parameters

  • initialState (Required): The initial value of the state. If it is a function, that function will be called to lazily initialize the state.

When updating based on the previous state, use the function version of setState:

import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((prevCount) => prevCount + 1)}>
Count: {count}
</button>
);
}

Pitfalls:

  • Directly mutating state instead of using the setter function (setState) or not passing a new object to the setter function
  • Referencing stale values in closures and updating with stale values; use the function version of setState if possible
  • Using useState when a value doesn't affect rendering; use useRef instead

Further reading on react.dev: useState - React

Common mistake: mutating state directly

const [user, setUser] = useState({ name: 'Alice', age: 25 });
user.age = 26; // ❌ This won't trigger a re-render

It is also not a good practice to re-use state objects:

function onClick() {
user.age = 26;
setUser(user); // ❌ Calling setter with the same object
}

Instead, create a new object:

setUser((prevUser) => ({ ...prevUser, age: 26 }));

Common mistake: updating state without considering previous state

setCount(count + 1); // Potential stale state issue if used within intervals or async functions

One way to fix this is to use functional version of setter if your new state relies on the previous state.

setCount((prevCount) => prevCount + 1);

useEffect

useEffect is a hook primarily for synchronizing a component with an external system. It can be used for executing side effects, such as fetching data, subscribing to events, or interacting with the DOM.

useEffect(effectFunction, dependencyArray);

Parameters

  1. effectFunction (Required): A function that contains the side effect logic
  2. dependencies (Optional): An array of values that determine when the effect runs

Depending on when the effect should run, the dependencies array is defined accordingly:

When should the effect run?Dependency arrayExample
After every renderNoneuseEffect(() => {...});
Once on mount[] (empty)useEffect(() => {...}, []);
When any dependency changes[var1, var2]useEffect(() => {...}, [var1, var2]);
Cleaning up (runs on unmount or before effect re-runs)VariesuseEffect(() => { return () => {...}; }, []);

useEffect is primarily for side effects / interacting with external services and that isn't too common during interviews. If you find yourself reaching for useEffect in your solutions, consider if there are alternatives.

import { useState, useEffect } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data')
.then((response) => response.json())
.then(setData);
}, []);
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

Pitfalls:

  • Not adding dependencies in the dependency array leading to stale closures
  • Causing infinite loops by updating state inside useEffect without proper dependency management
  • Unnecessary re-renders due to using objects/arrays in the dependency array
  • Not cleaning up side effects such as clearing timers and unsubscribing from events

It's a good practice to provide a dependency array in useEffect, useCallback, and useMemo to avoid unexpected behavior. However in future the React compiler aims to remove the need for writing useCallback and useMemo manually.

Read the manual

The unfortunate truth is that useEffect is just full of footguns and is hard to use, even for experienced engineers.

The useEffect hook is especially hard to get right, even for seasoned developers. Our recommendation is to avoid using useEffect in interviews if there are alternatives.

Given that interview questions are mostly self-contained, useEffects aren't too commonly needed. Hence you will find that the official solutions by GreatFrontEnd's React user interface questions do not have too much useEffect usage. If you are using useEffect in your code, compare with the official solutions to see how you can possibly avoid using it.

Either way, it's best to read the following pages of the React documentation to understand useEffect better:

  1. Synchronizing with Effects
  2. You Might Not Need an Effect
  3. Lifecycle of Reactive Effects
  4. Separating Events from Effects
  5. Removing Effect Dependencies

Further reading on react.dev: useEffect - React

Common mistake: missing dependencies leading to stale closures

useEffect(() => {
fetchData();
}, []); // What if fetchData depends on props?

Ensure dependencies are correct, or use a callback inside useEffect:

useEffect(() => {
fetchData(someProp);
}, [someProp]);

Common mistake: unnecessary effect invocations due to object/array dependency in useEffect

In the following example the effect should only run when filteredTodos change, but effect still runs when the "Force re-render" button is triggered. This is because a new filteredTodos array is recreated on every render, causing useEffect to run unnecessarily.

function TodoList({ todos }) {
const [count, setCount] = useState(0);
const [status, setStatus] = useState('in_progress');
const filteredTodos = todos.filter((todo) => todo.status === status);
useEffect(() => {
console.log('filteredTodos have changed');
}, [filteredTodos]);
return (
<div>
<button onClick={() => setCount(count + 1)}>Force re-render</button>
<button onClick={() => setStatus('complete')}>Change status</button>
</div>
);
}

Use useMemo to memoize the object, ensuring that filteredTodos retain the same reference unless the todos or status change. Even though todos is an array, it is a prop received from the parent and its still the same object reference when TodoList re-renders.

In the following example, triggering the "Force re-render" button will not cause the effect to re-run.

function TodoList({ todos }) {
const [count, setCount] = useState(0);
const [status, setStatus] = useState('in_progress');
const filteredTodos = useMemo(
() => todos.filter((todo) => todo.status === status),
[todos, status],
);
useEffect(() => {
console.log('filteredTodos have changed');
}, [filteredTodos]);
return (
<div>
<button onClick={() => setCount(count + 1)}>Force re-render</button>
<button onClick={() => setStatus('complete')}>Change status</button>
</div>
);
}

Common mistake: memory leaks due to missing cleanup

useEffect(() => {
const interval = setInterval(() => {
console.log('Running...');
}, 1000);
}, []); // No cleanup

Return a cleanup function when needed:

useEffect(() => {
const interval = setInterval(() => {
console.log('Running...');
}, 1000);
return () => clearInterval(interval);
}, []);

useContext

useContext provides a way to share state across components without prop drilling. It's useful for global state like themes, authentication, or user settings.

import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<button
onClick={() => {
setTheme(theme === 'light' ? 'dark' : 'light');
}}>
Toggle theme
</button>
<ThemeComponent />
</ThemeContext.Provider>
);
}
function ThemedComponent() {
const theme = useContext(ThemeContext);
return (
<div style={{ backgroundColor: theme === 'dark' ? '#000' : '#fff' }}>
Theme: {theme}
</div>
);
}

Note that in React 19, Context providers can use <ThemeContext> directly without doing <ThemeContext.Provider>.

React automatically re-renders all the children that use a particular context starting from the provider that receives a different value.

Pitfalls:

  • Using objects/functions as context values without memoization, leading to unnecessary re-renders
  • Overusing useContext for deeply nested state that might be better managed with a state management library

Further reading on react.dev: useContext - React

Common mistake: using objects/functions as context values without memoization

Here the context value is a list of todos. Whenever TodoListContainer re-renders (for example, on a route update), this will be a new array created thanks to the filtering, so React will also have to re-render all components deep in the tree that call useContext(TodoListContext).

filteredTodos is recomputed on every render and creates a new array instance, even if todos or status haven't changed. This can cause unnecessary re-renders.

function TodoListContainer() {
const [todos, setTodos] = useState([
{ title: 'Fix bug', status: 'in_progress' },
{ title: 'Add button', status: 'completed' },
]);
const [status, setStatus] = useState('in_progress');
const filteredTodos = todos.filter((todo) => todo.status === status);
return (
<TodoListContext.Provider value={filteredTodos}>
<TodoList />
</TodoListContext.Provider>
);
}

We can use useMemo to memoize filteredTodos, so it's only recalculated when todos or status change. This optimization can be significant for large lists.

function TodoListContainer() {
const [todos, setTodos] = useState([
{ title: 'Fix bug', status: 'in_progress' },
{ title: 'Add button', status: 'completed' },
]);
const [status, setStatus] = useState('in_progress');
const filteredTodos = useMemo(
() => todos.filter((todo) => todo.status === status),
[todos, status],
);
return (
<TodoListContext.Provider value={filteredTodos}>
<TodoList />
</TodoListContext.Provider>
);
}

useRef

useRef stores a mutable reference that persists across renders, commonly used for:

  • References data that is specific to the component instance
  • Storing references to the DOM
  • Storing data that does not affect the UI of the component (e.g. timeout or interval IDs). Mutating the ref values does not cause re-renders
import { useRef } from 'react';
function FocusInput() {
const inputRef = useRef(null);
return (
<div>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Focus Input</button>
</div>
);
}

Pitfalls:

  • Overusing useRef for state instead of useState

Further reading on react.dev: useRef - React

useId

useId is a hook introduced in React 18 that generates unique IDs for accessibility attributes and form elements. It ensures that the IDs are unique within a component tree, even when server-rendering.

import { useId } from 'react';
function Form() {
const id = useId();
return (
<div>
<label htmlFor={id}>Name:</label>
<input id={id} type="text" />
</div>
);
}
  • Primarily design system components where IDs are used for accessibility attributes (like aria-labelledby) yet they need to have unique values because there can be multiple component instances on the page
  • Generating unique IDs for form inputs, especially in server-rendered applications
  • Avoiding having to manually generating unique IDs with libraries or global counters

Pitfalls:

  • Do not use useId for keys in lists. Use stable values like database IDs instead
  • It is only useful for generating static IDs and should not be used to track dynamic state

Further reading on react.dev: useId - React

Rules of hooks

React hooks follow a strict set of rules to ensure they function correctly and maintain state consistency across renders. Violating these rules can lead to bugs, unintended behavior, or broken components.

  1. Only call hooks at the top level: Do not call hooks inside loops, conditions, nested functions, or try/catch/finally blocks
  2. Only call hooks from React function components or custom hooks: Avoid using hooks in regular JavaScript functions

Examples of incorrect vs correct hooks usage:

Do not call hooks inside conditions

Wrong: Call hooks inside a condition

if (someCondition) {
// ❌ Wrong: Hook inside a condition
const [count, setCount] = useState(0);
}

Correct: Call hooks at the top level

const [count, setCount] = useState(0);
if (someCondition) {
console.log(count);
}

Hooks must be called in the same order on every render. Conditional hooks may cause React to misalign state updates.

Only call hooks within React components or custom hooks

Wrong: Call a hook outside a component

// ❌ Wrong: Outside of the component
const count = useState(0);
function App() {
// ...
}

Correct: Call hooks inside functional components or custom hooks

function App() {
const [count, setCount] = useState(0);
return <p>{count}</p>;
}
function useCounter() {
const [count, setCount] = useState(0);
return [count, () => setCount((x) => x + 1)];
}

Hooks rely on React's render cycle and component state. Calling hooks outside a component prevents React from tracking state.

Do not call hooks after a conditional return

Wrong: Call hooks conditionally

function Counter({ hidden }) {
if (hidden) {
return;
}
// ❌ Wrong: After a conditional return
const [count, setCount] = useState(0);
// ...
}

Correct: Call hooks without a condition

function Counter({ hidden }) {
const [count, setCount] = useState(0);
if (hidden) {
return;
}
// ...
}

Do not calling hooks within loops

Wrong: Call hooks inside a loop

for (let i = 0; i < 2; i++) {
// ❌ Wrong: Hook inside a loop
const [count, setCount] = useState(0);
}

Correct: Call hooks without a loop

const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);

React assumes hooks are called in the same order every render. Calling them in a different order or different number of calls can break React's internal state tracking.

Do not call in event handlers

Wrong: Call hooks inside an event handler

function Counter() {
function handleClick() {
// ❌ Wrong: Inside an event handler
const theme = useContext(ThemeContext);
}
// ...
}

Correct: Call hooks outside event handler

function Counter() {
const theme = useContext(ThemeContext);
function handleClick() {
// ...
}
// ...
}

Further reading on react.dev: Rules of Hooks

Custom hooks

Custom hooks in React allow you to extract reusable logic from components while maintaining state and side effects using built-in hooks like useState, useEffect, useMemo, and others.

They follow the same rules as React hooks but enable better code reuse, abstraction, and separation of concerns in a clean and maintainable way.

  1. Code reusability: Instead of duplicating logic across components, a custom Hook encapsulates the logic and makes it reusable
  2. Separation of concerns: Components should focus on UI rendering, while custom hooks handle logic (state management, fetching data, event listeners, etc.)
  3. Cleaner & more readable components: Extracting logic into a Hook makes components less cluttered and more focused on presentation
  4. Encapsulation of side effects: Custom hooks allow managing side effects (like API calls) separately, making debugging and testing easier

A custom hook:

  • Is a JavaScript function that starts with use (e.g., useCounter, useFetch)
  • Must call other React hooks (e.g., useState, useEffect). If a custom hook does not call any React hooks, then it doesn't need to be a hook
  • Keep them focused – one purpose per hook
  • Optionally, returns state or functions that components can use

Here's an example of a useCounter custom hook. The value of such a hook is that it does not expose the setCount function and users of the hook are restricted to the exposed operations, they can only increment, decrement, or reset the value.

import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
function CounterComponent() {
const { count, increment, decrement, reset } = useCounter(10);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}

Further reading on react.dev: Reusing Logic with Custom Hooks

What you need to know for interviews

  1. Explain the purpose of hooks: Why React introduced hooks and how they replace class component lifecycle methods
  2. Know the common hooks: Understand when and why to use useState, useEffect, useContext, and useRef.
    • useState: Use the functional update form of setState when state depends on the previous state e.g. setCount((prevCount) => prevCount + 1). This also eliminates the problem of stale closures within the event handlers
  3. Understand re-rendering issues: Recognize how dependency arrays impact performance and how useMemo/useCallback optimize rendering
  4. Implement a custom hook: Be able to write and explain a reusable custom hook in an interview
  5. Debug common hook pitfalls: Identify and fix common mistakes like missing dependencies in useEffect or unnecessary state updates causing re-renders

Practice questions

Quiz:

Coding: