Hooks

13 min read
Rapid overview

React Hooks - Complete Guide

Master React Hooks from basics to advanced patterns for senior-level interviews.

Table of Contents


useState

Basic Usage

import { useState } from 'react';

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>
        Increment (functional)
      </button>
    </div>
  );
}

Functional Updates

// ❌ Bad - stale closure problem
function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
    setCount(count + 1); // Still only increments by 1
  };

  return <button onClick={increment}>+2</button>;
}

// ✅ Good - functional updates
function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1); // Correctly increments by 2
  };

  return <button onClick={increment}>+2</button>;
}

Lazy Initialization

// ❌ Bad - expensive computation runs on every render
function ExpensiveComponent() {
  const [data, setData] = useState(computeExpensiveValue());
  // computeExpensiveValue() runs on every render
}

// ✅ Good - lazy initialization runs only once
function ExpensiveComponent() {
  const [data, setData] = useState(() => computeExpensiveValue());
  // computeExpensiveValue() runs only on initial render
}

Multiple State Variables

function UserProfile() {
  // ❌ Bad - single object state
  const [state, setState] = useState({
    name: '',
    email: '',
    age: 0
  });

  const updateName = (name) => {
    setState({ ...state, name }); // Easy to forget spreading
  };

  // ✅ Good - separate state for independent values
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState(0);
}

useEffect

Basic Side Effects

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

  useEffect(() => {
    setLoading(true);

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]); // Re-run when userId changes

  if (loading) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

Cleanup Functions

function ChatRoom({ roomId }) {
  useEffect(() => {
    const socket = connectToRoom(roomId);

    // Cleanup runs when component unmounts or roomId changes
    return () => {
      socket.disconnect();
    };
  }, [roomId]);

  return <div>Chat Room: {roomId}</div>;
}

// Event listeners cleanup
function WindowSize() {
  const [size, setSize] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setSize(window.innerWidth);

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty array = run once on mount

  return <div>Width: {size}px</div>;
}

Common Mistakes

// ❌ Bad - missing dependency
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, []); // Missing 'query' dependency!

  return <div>{results.length} results</div>;
}

// ✅ Good - all dependencies included
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, [query]);

  return <div>{results.length} results</div>;
}

// ❌ Bad - infinite loop
function Component() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // Causes infinite loop!
  }); // No dependency array = runs after every render

  return <div>{count}</div>;
}

Async Effects

// ❌ Bad - async useEffect
useEffect(async () => {
  const data = await fetchData(); // Don't do this
  setData(data);
}, []);

// ✅ Good - async function inside
useEffect(() => {
  async function loadData() {
    const data = await fetchData();
    setData(data);
  }

  loadData();
}, []);

// ✅ Better - with cleanup for race conditions
useEffect(() => {
  let cancelled = false;

  async function loadData() {
    const data = await fetchData();
    if (!cancelled) {
      setData(data);
    }
  }

  loadData();

  return () => {
    cancelled = true;
  };
}, []);

// ✅ Best - with AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error(err);
      }
    });

  return () => controller.abort();
}, []);

useContext

Basic Context Usage

import { createContext, useContext } from 'react';

// Create context
const ThemeContext = createContext('light');

// Provider
function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={theme}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// Consumer
function ThemedButton() {
  const theme = useContext(ThemeContext);

  return (
    <button className={`btn-${theme}`}>
      Themed Button
    </button>
  );
}

Complex Context with Actions

const UserContext = createContext(null);

function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = async (credentials) => {
    const user = await api.login(credentials);
    setUser(user);
  };

  const logout = () => {
    setUser(null);
  };

  const value = {
    user,
    login,
    logout,
    isAuthenticated: !!user
  };

  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

// Custom hook for easier usage
function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser must be used within UserProvider');
  }
  return context;
}

// Usage
function Profile() {
  const { user, logout } = useUser();

  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

useReducer

Basic Reducer

import { useReducer } from 'react';

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

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

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

Complex State Management

const initialState = {
  items: [],
  loading: false,
  error: null
};

function todoReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };

    case 'FETCH_SUCCESS':
      return { ...state, loading: false, items: action.payload };

    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };

    case 'ADD_TODO':
      return { ...state, items: [...state.items, action.payload] };

    case 'REMOVE_TODO':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };

    case 'TOGGLE_TODO':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload
            ? { ...item, completed: !item.completed }
            : item
        )
      };

    default:
      return state;
  }
}

function TodoList() {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCH_START' });

    fetch('/api/todos')
      .then(res => res.json())
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
  }, []);

  const addTodo = (text) => {
    const newTodo = { id: Date.now(), text, completed: false };
    dispatch({ type: 'ADD_TODO', payload: newTodo });
  };

  return (
    <div>
      {state.loading && <div>Loading...</div>}
      {state.error && <div>Error: {state.error}</div>}
      <ul>
        {state.items.map(item => (
          <li key={item.id}>
            <input
              type="checkbox"
              checked={item.completed}
              onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: item.id })}
            />
            {item.text}
            <button onClick={() => dispatch({ type: 'REMOVE_TODO', payload: item.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

useCallback

Preventing Unnecessary Re-renders

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

// Child component that should only re-render when onClick changes
const ExpensiveChild = memo(({ onClick, data }) => {
  console.log('ExpensiveChild rendered');
  return <button onClick={onClick}>{data}</button>;
});

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

  // ❌ Bad - new function created on every render
  const handleClick = () => {
    console.log('Clicked!');
  };

  // ✅ Good - function reference stays the same
  const handleClickMemoized = useCallback(() => {
    console.log('Clicked!');
  }, []); // Empty deps = never changes

  // ✅ Good - function updates when count changes
  const handleIncrement = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  // ✅ Better - functional update doesn't need dependency
  const handleIncrementBetter = useCallback(() => {
    setCount(c => c + 1);
  }, []); // Can stay empty with functional update

  return (
    <div>
      <p>Count: {count}</p>
      <p>Other: {other}</p>
      <button onClick={() => setOther(other + 1)}>Change Other</button>
      <ExpensiveChild onClick={handleClickMemoized} data="Click me" />
    </div>
  );
}

useMemo

Expensive Calculations

import { useMemo } from 'react';

function DataGrid({ items, sortBy }) {
  // ❌ Bad - sorts on every render
  const sortedItems = items.sort((a, b) =>
    a[sortBy] > b[sortBy] ? 1 : -1
  );

  // ✅ Good - only re-sorts when items or sortBy changes
  const sortedItems = useMemo(() => {
    console.log('Sorting items...');
    return items.sort((a, b) =>
      a[sortBy] > b[sortBy] ? 1 : -1
    );
  }, [items, sortBy]);

  return (
    <ul>
      {sortedItems.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

// Memoizing objects to prevent reference changes
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // ❌ Bad - new object every render
  const config = { userId, settings: { theme: 'dark' } };

  // ✅ Good - same object reference unless userId changes
  const config = useMemo(() => ({
    userId,
    settings: { theme: 'dark' }
  }), [userId]);

  useEffect(() => {
    fetchUser(config).then(setUser);
  }, [config]); // Won't trigger unnecessarily

  return <div>{user?.name}</div>;
}

useRef

Accessing DOM Elements

import { useRef, useEffect } from 'react';

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

  useEffect(() => {
    // Focus input on mount
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} type="text" />;
}

Storing Mutable Values

function Timer() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);

    return () => {
      clearInterval(intervalRef.current);
    };
  }, []);

  const stopTimer = () => {
    clearInterval(intervalRef.current);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={stopTimer}>Stop</button>
    </div>
  );
}

// Tracking previous value
function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>Current: {count}, Previous: {prevCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

useImperativeHandle

Exposing Custom Instance Methods

import { forwardRef, useImperativeHandle, useRef } from 'react';

const FancyInput = forwardRef((props, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    clear: () => {
      inputRef.current.value = '';
    },
    getValue: () => {
      return inputRef.current.value;
    }
  }));

  return <input ref={inputRef} {...props} />;
});

function Parent() {
  const fancyInputRef = useRef();

  const handleSubmit = () => {
    const value = fancyInputRef.current.getValue();
    console.log('Value:', value);
    fancyInputRef.current.clear();
  };

  return (
    <div>
      <FancyInput ref={fancyInputRef} />
      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}

useLayoutEffect

Synchronous DOM Mutations

import { useLayoutEffect, useRef, useState } from 'react';

// Measuring DOM before paint
function Tooltip({ children }) {
  const [tooltipHeight, setTooltipHeight] = useState(0);
  const tooltipRef = useRef(null);

  useLayoutEffect(() => {
    const { height } = tooltipRef.current.getBoundingClientRect();
    setTooltipHeight(height);
  }, [children]);

  return (
    <div
      ref={tooltipRef}
      style={{ transform: `translateY(-${tooltipHeight}px)` }}
    >
      {children}
    </div>
  );
}

// useEffect vs useLayoutEffect
function Component() {
  const [show, setShow] = useState(false);

  // ❌ useEffect - may cause flicker
  useEffect(() => {
    if (show) {
      // DOM mutation after paint
      element.style.color = 'red';
    }
  }, [show]);

  // ✅ useLayoutEffect - no flicker
  useLayoutEffect(() => {
    if (show) {
      // DOM mutation before paint
      element.style.color = 'red';
    }
  }, [show]);
}

Custom Hooks

Data Fetching Hook

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

  useEffect(() => {
    let cancelled = false;

    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setData(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Local Storage Hook

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Toggle Theme ({theme})
    </button>
  );
}

Debounce Hook

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// Usage
function SearchInput() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      // API call only after user stops typing for 500ms
      searchAPI(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search..."
    />
  );
}

Rules of Hooks

The Two Rules

  1. Only call hooks at the top level
  • Don't call hooks inside loops, conditions, or nested functions
  1. Only call hooks from React functions
  • React function components
  • Custom hooks
// ❌ Bad - conditional hook
function Component({ shouldFetch }) {
  if (shouldFetch) {
    const [data, setData] = useState(null); // DON'T DO THIS
  }
}

// ✅ Good - hook at top level
function Component({ shouldFetch }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    if (shouldFetch) {
      fetchData().then(setData);
    }
  }, [shouldFetch]);
}

// ❌ Bad - hook in loop
function Component({ items }) {
  return items.map(item => {
    const [selected, setSelected] = useState(false); // DON'T DO THIS
    return <div>{item}</div>;
  });
}

// ✅ Good - separate component
function Item({ item }) {
  const [selected, setSelected] = useState(false);
  return <div>{item}</div>;
}

function Component({ items }) {
  return items.map(item => <Item key={item.id} item={item} />);
}

Questions & Answers

Q: When should I use useCallback vs useMemo?

A: Use useCallback to memoize functions, useMemo to memoize values. useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Q: What's the difference between useEffect and useLayoutEffect?

A: useEffect runs after paint (asynchronous), useLayoutEffect runs before paint (synchronous). Use useLayoutEffect when you need to measure/mutate DOM before browser paints.

Q: When should I use useReducer instead of useState?

A: Use useReducer when you have complex state logic with multiple sub-values, when next state depends on previous state, or when you want to optimize performance by passing dispatch down instead of callbacks.

Q: How do I handle async operations in useEffect?

A: Create an async function inside the effect and call it, or use .then(). Never make the effect callback itself async. Always handle cleanup for race conditions.

Q: What causes infinite loops in useEffect?

A: Missing dependency array runs effect after every render. Including objects/arrays created during render as dependencies causes new references every render. Use useMemo or move object creation inside the effect.

Q: When should I create a custom hook?

A: When you have reusable stateful logic used in multiple components, complex logic that clutters components, or when you want to encapsulate related hooks together.

Q: Is it okay to call hooks in loops?

A: No, hooks must be called in the same order every render. Dynamic hook calls break React's internal tracking. Create separate components instead.

Q: How do I clean up subscriptions in useEffect?

A: Return a cleanup function from the effect. It runs before the effect re-runs and when component unmounts.

Q: What's the purpose of useRef?

A: Store mutable values that don't trigger re-renders when changed, access DOM elements, keep instance variables between renders, and track previous values.

Q: How do I optimize re-renders with hooks?

A: Use React.memo for components, useCallback for functions, useMemo for expensive calculations, split state to prevent unnecessary updates, and lift state only when needed.