The Ultimate Guide to React Hooks

1 min read

What Are React Hooks? Your Ultimate Interview Guide

Before React 16.8, if you needed to add state or use lifecycle methods (like componentDidMount) to a component, you had to use a Class Component. This led to complex components, hard-to-follow logic, and wrapper hell.

React Hooks were introduced to solve this. They are functions that let you 'hook into' React's state and lifecycle features from Function Components.

This means you can now have state, side effects, and more, all in a simple, clean function.


The Two Golden Rules of Hooks (A Top Interview Question)

You must tell your interviewer these rules. They are critical.

  1. Only Call Hooks at the Top Level: You cannot call Hooks inside loops, conditions (if statements), or nested functions. They must be called at the top level of your React function.
  2. Only Call Hooks from React Functions: You can only call Hooks from React Function Components or from your own Custom Hooks. You can't call them from regular JavaScript functions.

This rule ensures that Hooks are called in the same order on every render, which is how React keeps track of their state.


The Main Hooks to Know:

  • useState: For adding state to a component.
  • useEffect: For performing side effects (data fetching, timers, DOM manipulation).
  • useContext: For accessing a React Context (avoids prop drilling).
  • useReducer: An alternative to useState for complex state logic.
  • useMemo: For memoizing expensive calculations (a value).
  • useCallback: For memoizing functions, often to prevent unnecessary re-renders in child components.

This guide will link to deep dives on each of these critical hooks.

useState vs. useReducer: When to Use Each in React

Interview Question: 'When would you use useReducer over useState?'

This is a common question to check if you understand how to manage complex state. Here's a simple way to answer.

1. useState: For Simple State

Use useState when your state is simple, like a number, string, boolean, or a simple object/array.

Best for:

  • A counter
  • A 'loading' boolean
  • A form input's string value

Code Example: Simple Counter with useState

import React, { useState } from 'react';

function Counter() {
  // Returns the value, and a setter function
  const [count, setCount] = useState(0);

  return (
    

Count: {count}

); }

2. useReducer: For Complex State

Use useReducer when your state logic is complex, or when the next state value depends on the previous one. It's also great when you have multiple state values that often change together (like in a complex form).

It takes a reducer function and initialState, and returns the state and a dispatch function.

Best for:

  • Managing a shopping cart (add, remove, update quantity).
  • Complex forms with many related fields.
  • Any state where the next state is calculated from the previous state (like our counter!).

Code Example: Same Counter with useReducer

import React, { useReducer } from 'react';

// 1. The Reducer function: (currentState, action) => newState
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

function Counter() {
  // 2. Initialize the hook
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    

Count: {state.count}

{/ 3. Dispatch actions instead of setting state /}
); }

The simple answer: 'I use useState for simple state. I use useReducer when state logic is complex or when multiple state values are tied together, as it makes the logic more predictable and easier to test.'

useEffect: How to Handle Data Fetching and Cleanup

Interview Question: 'How do you fetch data with useEffect?'

useEffect is the hook for all 'side effects'—tasks that are outside of the normal render, like API calls, timers, or DOM manipulations.

The Dependency Array is Key

The second argument to useEffect is the 'dependency array'. It controls when the effect runs.

  • [] (Empty Array): The effect runs only once, when the component mounts. Perfect for initial data fetching.
  • [prop, state] (Has Values): The effect runs on mount AND any time the values of prop or state change.
  • No Array (Omitted): The effect runs after every single render. This is usually a bug and can cause infinite loops.

Code Example: Data Fetching

Here is how you correctly fetch data from an API when the component loads.

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // We must use an async function inside the effect
    // because the effect function itself cannot be async.
    async function fetchData() {
      setLoading(true);
      try {
        const response = await fetch(`https.api.example.com/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (error) {
        console.error("Failed to fetch user", error);
      }
      setLoading(false);
    }

    fetchData();

  }, [userId]); // <-- Dependency array: re-run if userId changes

  if (loading) return 

Loading...

; if (!user) return

No user found.

; return

Username: {user.name}

; }

The Cleanup Function

An interviewer might ask, 'What does useEffect return?'

Answer: 'It can return an optional cleanup function. React will run this function when the component unmounts, or before the effect runs again. It's essential for preventing memory leaks.'

Code Example: Cleanup with a Timer

useEffect(() => {
  // Side effect: start a timer
  const timerId = setInterval(() => {
    console.log('Timer ticked');
  }, 1000);

  // Cleanup function: runs on unmount
  return () => {
    console.log('Cleaning up the timer');
    clearInterval(timerId);
  };
}, []); // [] means this runs once on mount, cleans up on unmount

useCallback and useMemo: A Practical Guide to Performance

Interview Question: 'What's the difference between useMemo and useCallback?'

This is a performance-related question. Using them incorrectly can make performance worse. Here’s the simple difference.

  • useMemo memoizes a VALUE.
  • useCallback memoizes a FUNCTION.

'Memoization' just means caching the result. React will skip re-calculating (or re-creating) the value/function if the dependencies haven't changed, and will just use the cached version.


1. useMemo: Caching Expensive Calculations

Use useMemo when you have a 'slow' calculation that you don't want to re-run on every render.

Code Example:

import React, { useState, useMemo } from 'react';

// A 'slow' function
function expensiveCalculation(num) {
  console.log('Running slow calculation...');
  // ... imagine this takes a long time ...
  return num * 2;
}

function Calculator() {
  const [count, setCount] = useState(10);
  const [otherState, setOtherState] = useState(0);

  // This calculated value is 'memoized'.
  // It will ONLY re-run if 'count' changes.
  // It will NOT re-run if 'otherState' changes.
  const calculatedValue = useMemo(() => {
    return expensiveCalculation(count);
  }, [count]);

  return (
    

Calculated Value: {calculatedValue}

Other State: {otherState}

); }

2. useCallback: Caching Functions

In JavaScript, a function is a new object on every render. This is a problem when you pass that function as a prop to a child component that is wrapped in React.memo(). The child will re-render just because it got a 'new' (but identical) function prop.

useCallback gives you the same function instance back, as long as its dependencies don't change.

Code Example:

import React, { useState, useCallback } from 'react';

// A child component that is memoized
const ChildComponent = React.memo(function Child({ onClick }) {
  console.log('Child rendered');
  return ;
});

function Parent() {
  const [count, setCount] = useState(0);

  // This function is memoized.
  // React will return the same function instance every render.
  // This prevents ChildComponent from re-rendering unnecessarily.
  const handleClick = useCallback(() => {
    console.log('Clicked!');
    // Note: If we used 'count' here, we'd add it to the dependency array: [count]
  }, []);

  return (
    

Parent Count: {count}

); }

The simple answer: 'Use useMemo to cache the result of a slow calculation. Use useCallback to cache a function definition so you can pass a stable prop to a memoized child component.'

useContext: Ditching 'Prop Drilling' for Good

Interview Question: 'What is prop drilling and how do you solve it?'

This question tests your knowledge of basic React state management patterns.

What is Prop Drilling?

Prop Drilling is the term for when you have to pass props down through multiple layers of components, even when the components in the middle don't need or use those props. They just act as a 'pass-through'.

'Before' Code Example: Prop Drilling

Imagine a user's theme preference ('dark' or 'light') is needed by a Button component, but the state is held in the top-level App.

// App.js (Top Level)
function App() {
  const [theme, setTheme] = useState('light');
  return ;
}

// Toolbar.js (Middle Level - doesn't need theme)
function Toolbar({ theme }) {
  // ... just passes it down ...
  return ;
}

// ThemedButton.js (Deep Level - needs theme)
function ThemedButton({ theme }) {
  return ;
}

This is messy. If another component 10 levels deep needed theme, you'd have to 'drill' it all the way down.


How useContext Solves It

The Context API lets you create a 'global' state for a branch of your component tree. Any child can 'subscribe' to it directly without props.

It has 3 parts:

  1. React.createContext(): Creates the context object.
  2. MyContext.Provider: The component that 'provides' the value to all children.
  3. useContext(MyContext): The Hook that 'consumes' the value in any child component.

'After' Code Example: Using useContext

import React, { useState, useContext, createContext } from 'react';

// 1. Create the context
const ThemeContext = createContext('light'); // 'light' is the default

// App.js (Top Level)
function App() {
  const [theme, setTheme] = useState('light');

  // 2. Provide the value to all children
  return (
    
      
    
  );
}

// Toolbar.js (Middle Level - now it's clean!)
function Toolbar() {
  // ... doesn't even know about 'theme' ...
  return ;
}

// ThemedButton.js (Deep Level)
function ThemedButton() {
  // 3. Consume the value directly!
  const theme = useContext(ThemeContext);
  return ;
}

The simple answer: 'Prop drilling is passing props down many levels. I solve it with the useContext Hook, which lets a component consume a value from a Provider far above it in the tree, without passing props through the middle components.'

How to Create Your Own Custom React Hooks

Interview Question: 'Have you ever built a Custom Hook? How?'

This is a great question to show you've moved beyond the basics. The answer is surprisingly simple.

Answer: 'A Custom Hook is just a regular JavaScript function whose name starts with use. Its purpose is to extract and reuse component logic (that includes other hooks)'.

Instead of having the same useState and useEffect logic intwo different components (e.g., to fetch data, or to track window size), you put that logic into a custom hook and just call that hook in both components.


Rules for Custom Hooks:

  1. Name MUST start with use. This is how React knows it's a hook and can apply the Rules of Hooks. (e.g., useWindowSize, not getWindowSize).
  2. It can (and should) call other hooks like useState, useEffect, etc.
  3. It should return the values the component needs (e.g., the state, or functions to change it).

Code Example: A useWindowSize Custom Hook

Let's make a hook that tracks the browser window's width and height. This is a classic example.

import { useState, useEffect } from 'react';

// 1. Create the hook (a function starting with 'use')
export function useWindowSize() {
  // 2. Use other hooks (like useState) inside
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  // 3. Use other hooks (like useEffect) inside
  useEffect(() => {
    // This function updates the state
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // Set up the side effect (event listener)
    window.addEventListener('resize', handleResize);

    // Call handleResize right away to get the current size
    handleResize();

    // 4. Return the cleanup function
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Empty array = run only on mount and unmount

  // 5. Return the value your component needs
  return windowSize;
}

// --- NOW, IN ANY COMPONENT, YOU CAN USE IT ---

// MyComponent.js
import React from 'react';
import { useWindowSize } from './useWindowSize';

function MyComponent() {
  // 6. Use your hook just like a built-in one!
  const { width, height } = useWindowSize();

  return (
    

Window width: {width}px

Window height: {height}px

); }

This is powerful because now any component can get the window size by calling useWindowSize(), and all the complex logic is hidden away and reusable.

💬