Complete Guide to React Hooks

A complete beginner-to-advanced guide on mastering React Hooks, from useState and useEffect to custom hooks and performance optimization

Back

Complete Guide to React Hooks

1. useState Hook

Overview

useState is the most fundamental React hook that allows you to add state to functional components. It returns an array with two elements: the current state value and a function to update it.

Syntax

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

Key Points

  • The initial value can be a primitive, object, or array
  • State updates are asynchronous and may be batched
  • When updating objects or arrays, you must create a new reference
  • The setter function can accept a value or a callback function

Code Example

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState({ name: '', email: '' });

  const increment = () => {
    // Using callback function for state that depends on previous state
    setCount(prevCount => prevCount + 1);
  };

  const updateUser = (field, value) => {
    // Updating object state - must spread to create new object
    setUser(prevUser => ({
      ...prevUser,
      [field]: value
    }));
  };

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>Increment</button>
      
      <div>
        <input
          placeholder="Name"
          value={user.name}
          onChange={(e) => updateUser('name', e.target.value)}
        />
        <input
          placeholder="Email"
          value={user.email}
          onChange={(e) => updateUser('email', e.target.value)}
        />
        <p>User: {user.name} - {user.email}</p>
      </div>
    </div>
  );
}

2. useEffect Hook

Overview

useEffect handles side effects in functional components. It combines the functionality of componentDidMount, componentDidUpdate, and componentWillUnmount from class components.

Syntax

useEffect(() => {
  // Effect logic
  return () => {
    // Cleanup function (optional)
  };
}, [dependencies]); // Dependency array (optional)

Key Points

  • Runs after every render by default
  • Dependency array controls when effect runs
  • Empty dependency array [] means run only once (on mount)
  • No dependency array means run after every render
  • Return function is for cleanup

Code Example

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

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

  // Effect with dependency - runs when userId changes
  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true);
      try {
        // Simulating API call
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (error) {
        console.error('Error fetching user:', error);
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      fetchUser();
    }
  }, [userId]); // Only run when userId changes

  // Effect with cleanup - runs once on mount
  useEffect(() => {
    const handleResize = () => {
      setWindowWidth(window.innerWidth);
    };

    // Add event listener
    window.addEventListener('resize', handleResize);

    // Cleanup function - removes event listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty dependency array - runs only once

  // Effect without dependencies - runs after every render
  useEffect(() => {
    document.title = user ? `Profile: ${user.name}` : 'Loading...';
  });

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h2>User Profile</h2>
      <p>Window Width: {windowWidth}px</p>
      {user && (
        <div>
          <h3>{user.name}</h3>
          <p>Email: {user.email}</p>
        </div>
      )}
    </div>
  );
}

3. useRef Hook

Overview

useRef creates a mutable reference that persists across renders. It's commonly used for accessing DOM elements directly or storing mutable values that don't trigger re-renders.

Syntax

const ref = useRef(initialValue);

Key Points

  • .current property holds the actual value
  • Changing .current doesn't trigger re-renders
  • Perfect for DOM manipulation and storing previous values
  • Useful for accessing child component methods

Code Example

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

function FocusInput() {
  const [count, setCount] = useState(0);
  const inputRef = useRef(null);
  const previousCountRef = useRef();
  const renderCountRef = useRef(0);

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

  // Store previous count value
  useEffect(() => {
    previousCountRef.current = count;
  });

  // Track render count
  renderCountRef.current += 1;

  const handleFocus = () => {
    // Direct DOM manipulation
    inputRef.current.focus();
    inputRef.current.select();
  };

  const handleClear = () => {
    // Clear input value directly
    inputRef.current.value = '';
    inputRef.current.focus();
  };

  return (
    <div>
      <h2>useRef Example</h2>
      
      <div>
        <input
          ref={inputRef}
          placeholder="Type something..."
          defaultValue="Hello World"
        />
        <button onClick={handleFocus}>Focus & Select</button>
        <button onClick={handleClear}>Clear</button>
      </div>

      <div>
        <p>Current count: {count}</p>
        <p>Previous count: {previousCountRef.current}</p>
        <p>Render count: {renderCountRef.current}</p>
        <button onClick={() => setCount(count + 1)}>
          Increment Count
        </button>
      </div>
    </div>
  );
}

4. useContext Hook

Overview

useContext allows you to consume context values without wrapping components in Context.Consumer. It provides a clean way to access context data throughout your component tree.

Syntax

const contextValue = useContext(MyContext);

Key Points

  • Must be used within a Context Provider
  • Automatically re-renders when context value changes
  • Cleaner alternative to Context.Consumer
  • Good for avoiding prop drilling

Code Example

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

// Create contexts
const ThemeContext = createContext();
const UserContext = createContext();

// Theme Provider Component
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// User Provider Component
function UserProvider({ children }) {
  const [user, setUser] = useState({ name: 'John Doe', role: 'admin' });

  const updateUser = (updates) => {
    setUser(prevUser => ({ ...prevUser, ...updates }));
  };

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

// Component using multiple contexts
function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  const { user } = useContext(UserContext);

  return (
    <header style={{
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#000' : '#fff',
      padding: '1rem'
    }}>
      <h1>Welcome, {user.name}!</h1>
      <button onClick={toggleTheme}>
        Switch to {theme === 'light' ? 'dark' : 'light'} theme
      </button>
    </header>
  );
}

// Nested component accessing context
function UserProfile() {
  const { user, updateUser } = useContext(UserContext);
  const { theme } = useContext(ThemeContext);

  return (
    <div style={{
      backgroundColor: theme === 'light' ? '#f5f5f5' : '#444',
      color: theme === 'light' ? '#000' : '#fff',
      padding: '1rem',
      margin: '1rem'
    }}>
      <h2>User Profile</h2>
      <p>Name: {user.name}</p>
      <p>Role: {user.role}</p>
      <button onClick={() => updateUser({ name: 'Jane Smith' })}>
        Change Name
      </button>
    </div>
  );
}

// Main App Component
function App() {
  return (
    <ThemeProvider>
      <UserProvider>
        <Header />
        <UserProfile />
      </UserProvider>
    </ThemeProvider>
  );
}

5. useMemo Hook

Overview

useMemo memoizes the result of a computation and only recalculates when dependencies change. It's used for performance optimization to avoid expensive calculations on every render.

Syntax

const memoizedValue = useMemo(() => {
  return expensiveCalculation();
}, [dependencies]);

Key Points

  • Only recalculates when dependencies change
  • Helps prevent expensive calculations on every render
  • Returns the memoized value, not a function
  • Don't overuse - has its own overhead

Code Example

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

function ExpensiveCalculationComponent() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  // Expensive calculation that we want to memoize
  const expensiveValue = useMemo(() => {
    console.log('Calculating expensive value...');
    let result = 0;
    for (let i = 0; i < 1000000; i++) {
      result += i * count;
    }
    return result;
  }, [count]); // Only recalculate when count changes

  // Memoized filtered todos
  const filteredTodos = useMemo(() => {
    console.log('Filtering todos...');
    switch (filter) {
      case 'completed':
        return todos.filter(todo => todo.completed);
      case 'active':
        return todos.filter(todo => !todo.completed);
      default:
        return todos;
    }
  }, [todos, filter]); // Recalculate when todos or filter changes

  // Memoized statistics
  const todoStats = useMemo(() => {
    console.log('Calculating todo statistics...');
    return {
      total: todos.length,
      completed: todos.filter(todo => todo.completed).length,
      active: todos.filter(todo => !todo.completed).length
    };
  }, [todos]);

  const addTodo = () => {
    setTodos(prevTodos => [
      ...prevTodos,
      {
        id: Date.now(),
        text: `Todo ${prevTodos.length + 1}`,
        completed: Math.random() > 0.5
      }
    ]);
  };

  const toggleTodo = (id) => {
    setTodos(prevTodos =>
      prevTodos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return (
    <div>
      <h2>useMemo Example</h2>
      
      <div>
        <p>Count: {count}</p>
        <p>Expensive Calculation Result: {expensiveValue}</p>
        <button onClick={() => setCount(count + 1)}>
          Increment Count
        </button>
      </div>

      <div>
        <h3>Todo Statistics</h3>
        <p>Total: {todoStats.total}</p>
        <p>Completed: {todoStats.completed}</p>
        <p>Active: {todoStats.active}</p>
      </div>

      <div>
        <button onClick={addTodo}>Add Todo</button>
        <select value={filter} onChange={(e) => setFilter(e.target.value)}>
          <option value="all">All</option>
          <option value="active">Active</option>
          <option value="completed">Completed</option>
        </select>
      </div>

      <div>
        <h3>Filtered Todos ({filteredTodos.length})</h3>
        {filteredTodos.map(todo => (
          <div key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              {todo.text}
            </label>
          </div>
        ))}
      </div>
    </div>
  );
}

6. useCallback Hook

Overview

useCallback memoizes a function and only creates a new function when dependencies change. It's primarily used to prevent unnecessary re-renders of child components that depend on callback functions.

Syntax

const memoizedCallback = useCallback(() => {
  // Function logic
}, [dependencies]);

Key Points

  • Returns a memoized function, not the result of the function
  • Prevents child component re-renders when passed as props
  • Only creates new function when dependencies change
  • Most useful with React.memo components

Code Example

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

// Child component wrapped with React.memo for optimization
const TodoItem = memo(({ todo, onToggle, onDelete }) => {
  console.log(`Rendering TodoItem: ${todo.text}`);
  
  return (
    <div style={{ 
      padding: '0.5rem', 
      margin: '0.25rem 0',
      backgroundColor: todo.completed ? '#e8f5e8' : '#fff3cd',
      border: '1px solid #ccc'
    }}>
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => onToggle(todo.id)}
        />
        <span style={{ 
          textDecoration: todo.completed ? 'line-through' : 'none',
          marginLeft: '0.5rem'
        }}>
          {todo.text}
        </span>
      </label>
      <button 
        onClick={() => onDelete(todo.id)}
        style={{ marginLeft: '1rem', color: 'red' }}
      >
        Delete
      </button>
    </div>
  );
});

// Another memoized child component
const AddTodoForm = memo(({ onAddTodo }) => {
  console.log('Rendering AddTodoForm');
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      onAddTodo(text.trim());
      setText('');
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: '1rem' }}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Enter todo text..."
        style={{ marginRight: '0.5rem', padding: '0.25rem' }}
      />
      <button type="submit">Add Todo</button>
    </form>
  );
});

// Main component using useCallback
function TodoApp() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  // Memoized callback functions
  const handleToggleTodo = useCallback((id) => {
    console.log('Toggle todo called');
    setTodos(prevTodos =>
      prevTodos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []); // No dependencies - function never changes

  const handleDeleteTodo = useCallback((id) => {
    console.log('Delete todo called');
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
  }, []); // No dependencies - function never changes

  const handleAddTodo = useCallback((text) => {
    console.log('Add todo called');
    const newTodo = {
      id: Date.now(),
      text,
      completed: false
    };
    setTodos(prevTodos => [...prevTodos, newTodo]);
  }, []); // No dependencies - function never changes

  // This callback depends on filter, so it will recreate when filter changes
  const getFilteredTodos = useCallback(() => {
    switch (filter) {
      case 'completed':
        return todos.filter(todo => todo.completed);
      case 'active':
        return todos.filter(todo => !todo.completed);
      default:
        return todos;
    }
  }, [todos, filter]); // Recreates when todos or filter changes

  const filteredTodos = getFilteredTodos();

  return (
    <div style={{ padding: '1rem' }}>
      <h2>useCallback Todo App</h2>
      
      <AddTodoForm onAddTodo={handleAddTodo} />
      
      <div style={{ marginBottom: '1rem' }}>
        <label>
          Filter: 
          <select 
            value={filter} 
            onChange={(e) => setFilter(e.target.value)}
            style={{ marginLeft: '0.5rem' }}
          >
            <option value="all">All ({todos.length})</option>
            <option value="active">
              Active ({todos.filter(t => !t.completed).length})
            </option>
            <option value="completed">
              Completed ({todos.filter(t => t.completed).length})
            </option>
          </select>
        </label>
      </div>

      <div>
        {filteredTodos.length === 0 ? (
          <p>No todos to display</p>
        ) : (
          filteredTodos.map(todo => (
            <TodoItem
              key={todo.id}
              todo={todo}
              onToggle={handleToggleTodo}
              onDelete={handleDeleteTodo}
            />
          ))
        )}
      </div>
    </div>
  );
}

Hook Best Practices

1. Rules of Hooks

  • Only call hooks at the top level of functions
  • Don't call hooks inside loops, conditions, or nested functions
  • Only call hooks from React functions or custom hooks

2. Performance Tips

  • Use useMemo and useCallback sparingly - they have overhead
  • Consider React.memo for expensive child components
  • Profile before optimizing

3. Common Patterns

  • Custom hooks for reusable logic
  • Combine multiple hooks for complex state management
  • Use useReducer for complex state transitions

4. Testing Considerations

  • Test hook behavior, not implementation
  • Use React Testing Library for hook testing
  • Consider creating custom hooks for complex logic

This guide covers the six most commonly used React hooks with practical examples that demonstrate real-world usage patterns and best practices.

Written by

Suraj Kumar Jha

At

Tue Jul 15 2025