All posts
React & Frontend

Mastering Hooks in React: An In-Depth Look

28 min readby imnb
ReactHooksuseStateuseEffectPerformanceJavaScript
Share

React Hooks revolutionized how we write React components when they were introduced in version 16.8. In this comprehensive guide, we'll build a Task Management Application from the ground up, progressively adding complexity while exploring each hook in depth.

Introduction

React Hooks revolutionized how we write React components when they were introduced in version 16.8. They allow us to use state and other React features without writing class components, leading to more readable, maintainable, and testable code. But mastering hooks goes beyond just knowing the syntax, it's about understanding when, why, and how to use them effectively.

In this comprehensive guide, we'll build a Task Management Application from the ground up, progressively adding complexity while exploring each hook in depth. By the end, you'll have a solid understanding of hooks and the confidence to use them in real-world scenarios.

What Are Hooks?

Hooks are special functions that let you "hook into" React features from function components. They enable you to manage state without converting to a class component, handle side effects like data fetching, subscriptions, or DOM manipulation, access context for global state management, optimize performance through memoization, and reuse stateful logic across components.

Before hooks, stateful logic required class components with lifecycle methods. Hooks provide a more direct API that's easier to understand and compose.

The Rules of Hooks

Before diving into individual hooks, understand these two critical rules:

Rule 1: Only Call Hooks at the Top Level

Never call hooks inside loops, conditions, or nested functions. This ensures hooks are called in the same order every render.

javascript
// ❌ WRONG
if (condition) {
  const [state, setState] = useState(initialValue);
}

// ✅ CORRECT
const [state, setState] = useState(initialValue);
if (condition) {
  // use state here
}

Rule 2: Only Call Hooks from React Functions

Call hooks only from React function components or custom hooks (which are called from React components).

javascript
// ❌ WRONG - regular function
function regularFunction() {
  const [state, setState] = useState(0);
}

// ✅ CORRECT - React component
function MyComponent() {
  const [state, setState] = useState(0);
}

// ✅ CORRECT - custom hook
function useCustomHook() {
  const [state, setState] = useState(0);
  return state;
}

useState: Managing Component State

useState is the most fundamental hook. It lets you add state to function components.

Basic Syntax

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

Building Our Task Manager: Step 1

Let's start with a simple task list:

javascript
function TaskList() {
  const [tasks, setTasks] = useState([]);
  const [inputValue, setInputValue] = useState('');

  const addTask = () => {
    if (inputValue.trim()) {
      setTasks([...tasks, {
        id: Date.now(),
        text: inputValue,
        completed: false
      }]);
      setInputValue('');
    }
  };

  return (
    <div>
      <input
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        placeholder="Add a task..."
      />
      <button onClick={addTask}>Add</button>

      <ul>
        {tasks.map(task => (
          <li key={task.id}>{task.text}</li>
        ))}
      </ul>
    </div>
  );
}

Functional Updates

When the next state depends on the previous state, use the functional form:

javascript
// ❌ Can cause bugs with multiple updates
setCount(count + 1);

// ✅ Always correct
setCount(prevCount => prevCount + 1);

Lazy Initialization

If initial state is expensive to compute, pass a function:

javascript
// ❌ Computed every render
const [state, setState] = useState(expensiveComputation());

// ✅ Computed only once
const [state, setState] = useState(() => expensiveComputation());

useEffect: Handling Side Effects

useEffect lets you perform side effects in function components. It combines the functionality of componentDidMount, componentDidUpdate, and componentWillUnmount from class components.

Basic Syntax

javascript
useEffect(() => {
  // Side effect code

  return () => {
    // Cleanup code (optional)
  };
}, [dependencies]);

Building Our Task Manager: Step 2

Let's add persistence to our task manager:

javascript
function TaskList() {
  const [tasks, setTasks] = useState(() => {
    const saved = localStorage.getItem('tasks');
    return saved ? JSON.parse(saved) : [];
  });

  // Save to localStorage whenever tasks change
  useEffect(() => {
    localStorage.setItem('tasks', JSON.stringify(tasks));
  }, [tasks]);

  // ... rest of component
}

Dependency Array Explained

The dependency array controls when the effect runs:

javascript
// Runs after every render
useEffect(() => {
  console.log('Every render');
});

// Runs only once (on mount)
useEffect(() => {
  console.log('Component mounted');
}, []);

// Runs when 'count' changes
useEffect(() => {
  console.log('Count changed:', count);
}, [count]);

useContext: Sharing Data Across Components

useContext lets you consume context values without wrapping components in Context.Consumer. Context is perfect for theme/styling across the app, user authentication state, language/localization settings, and global UI state (modals, notifications).

javascript
// Create context
const ThemeContext = React.createContext();

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

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

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

// Consumer component
function ThemedButton() {
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button
      onClick={toggleTheme}
      style={{
        background: theme === 'light' ? '#fff' : '#333',
        color: theme === 'light' ? '#333' : '#fff'
      }}
    >
      Toggle Theme ({theme})
    </button>
  );
}

useReducer: Complex State Management

useReducer is an alternative to useState for managing complex state logic. It's similar to Redux reducers and perfect for complex state logic, multiple state values that depend on each other, or when the next state depends on previous state.

javascript
// Define action types
const ACTIONS = {
  ADD_TASK: 'add_task',
  TOGGLE_TASK: 'toggle_task',
  DELETE_TASK: 'delete_task',
};

// Reducer function
function tasksReducer(state, action) {
  switch (action.type) {
    case ACTIONS.ADD_TASK:
      return {
        ...state,
        tasks: [...state.tasks, {
          id: Date.now(),
          text: action.payload.text,
          completed: false
        }]
      };
    case ACTIONS.TOGGLE_TASK:
      return {
        ...state,
        tasks: state.tasks.map(task =>
          task.id === action.payload.id
            ? { ...task, completed: !task.completed }
            : task
        )
      };
    default:
      return state;
  }
}

// Component using reducer
function TaskManager() {
  const [state, dispatch] = useReducer(tasksReducer, { tasks: [] });

  const addTask = () => {
    dispatch({
      type: ACTIONS.ADD_TASK,
      payload: { text: inputValue }
    });
  };
}

useCallback: Optimizing Function References

useCallback returns a memoized callback function that only changes if dependencies change. This prevents unnecessary re-renders of child components.

javascript
// Child component that receives callbacks
const TaskItem = React.memo(({ task, onToggle, onDelete }) => {
  console.log('TaskItem rendered:', task.id);

  return (
    <li>
      <input
        type="checkbox"
        checked={task.completed}
        onChange={() => onToggle(task.id)}
      />
      <span>{task.text}</span>
      <button onClick={() => onDelete(task.id)}>Delete</button>
    </li>
  );
});

// Parent component with useCallback
function TaskList() {
  const [tasks, setTasks] = useState([]);

  // ✅ With useCallback - same function reference
  const handleToggle = useCallback((id) => {
    setTasks(prevTasks => prevTasks.map(task =>
      task.id === id ? { ...task, completed: !task.completed } : task
    ));
  }, []); // Empty deps because we use functional update

  return (
    <ul>
      {tasks.map(task => (
        <TaskItem
          key={task.id}
          task={task}
          onToggle={handleToggle}
        />
      ))}
    </ul>
  );
}

useMemo: Memoizing Expensive Calculations

useMemo returns a memoized value that only recomputes when dependencies change. Use it for expensive calculations that shouldn't run on every render.

javascript
function TaskStatistics({ tasks }) {
  // ✅ With useMemo - only recalculated when tasks change
  const stats = useMemo(() => {
    console.log('Calculating statistics...');
    const completed = tasks.filter(t => t.completed).length;
    const total = tasks.length;

    return {
      total,
      completed,
      active: total - completed,
      completionRate: total > 0 ? (completed / total * 100).toFixed(1) : 0
    };
  }, [tasks]);

  return (
    <div>
      <h3>Task Statistics</h3>
      <p>Total: {stats.total}</p>
      <p>Completed: {stats.completed}</p>
      <p>Active: {stats.active}</p>
      <p>Completion Rate: {stats.completionRate}%</p>
    </div>
  );
}

useRef: Accessing DOM and Persisting Values

useRef returns a mutable ref object whose .current property persists across renders. Use it for accessing DOM elements, storing mutable values without re-rendering, and tracking previous values.

javascript
function AutoFocusInput() {
  const inputRef = useRef(null);

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

  return <input ref={inputRef} placeholder="Auto-focused" />;
}

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

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

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

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

Custom Hooks: Creating Reusable Logic

Custom hooks let you extract component logic into reusable functions. They must start with 'use' and can call other hooks.

useLocalStorage Hook

javascript
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 TaskManager() {
  const [tasks, setTasks] = useLocalStorage('tasks', []);
  // Now tasks automatically persist!
}

useDebounce Hook

javascript
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 SearchTasks() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      console.log('Searching for:', debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);
}

useFetch Hook

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

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

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error('Network response was not ok');
        const json = await response.json();

        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

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

  return { data, loading, error };
}

Common Pitfalls and How to Avoid Them

1. Stale Closures

Functions capture values from when they were created, leading to stale data. Always use functional updates when the next state depends on previous state.

javascript
// ❌ WRONG - closure captures initial count
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // Only increments once!
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ CORRECT - functional update
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1); // Always uses current value
  }, 1000);
  return () => clearInterval(id);
}, []);

2. Race Conditions in useEffect

Multiple async operations can complete out of order. Use cleanup functions to prevent stale updates.

javascript
// ❌ WRONG - race condition
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data); // Might set wrong user if userId changed!
    });
  }, [userId]);
}

// ✅ CORRECT - cleanup prevents stale updates
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

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

    fetchUser(userId).then(data => {
      if (!cancelled) setUser(data);
    });

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

3. Not Cleaning Up Subscriptions

Always return cleanup functions from useEffect to prevent memory leaks from subscriptions, timers, or event listeners.

javascript
// ❌ WRONG - memory leak
function Component() {
  useEffect(() => {
    window.addEventListener('resize', handleResize);
  }, []);
}

// ✅ CORRECT - cleanup
function Component() {
  useEffect(() => {
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
}

4. Premature Optimization

Don't use useMemo/useCallback everywhere. Only optimize when needed - after profiling shows performance issues.

Performance Optimization with Hooks

React.memo for Component Memoization

Prevent unnecessary re-renders of child components by wrapping them with React.memo. Only re-renders if props actually change.

javascript
// Without memo - re-renders every time parent renders
function TaskItem({ task, onToggle }) {
  return <div onClick={onToggle}>{task.text}</div>;
}

// With memo - only re-renders if props change
const TaskItem = React.memo(({ task, onToggle }) => {
  return <div onClick={onToggle}>{task.text}</div>;
});

// Custom comparison function
const TaskItem = React.memo(
  ({ task, onToggle }) => <div onClick={onToggle}>{task.text}</div>,
  (prevProps, nextProps) => {
    // Return true if props are equal (don't re-render)
    return prevProps.task.id === nextProps.task.id &&
           prevProps.task.completed === nextProps.task.completed;
  }
);

Lazy Loading Components

javascript
import { lazy, Suspense } from 'react';

// Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<div>Loading chart...</div>}>
        <HeavyChart />
      </Suspense>
    </div>
  );
}

Real-World Use Cases

Form Management

Create a custom useForm hook to handle form state, validation, and submission.

javascript
function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
  };

  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    const validationErrors = validate(values);
    setErrors(validationErrors);
  };

  const handleSubmit = (onSubmit) => (e) => {
    e.preventDefault();
    const validationErrors = validate(values);
    setErrors(validationErrors);
    if (Object.keys(validationErrors).length === 0) {
      onSubmit(values);
    }
  };

  return { values, errors, touched, handleChange, handleBlur, handleSubmit };
}

Real-Time Data with WebSockets

javascript
function useWebSocket(url) {
  const [data, setData] = useState(null);
  const [status, setStatus] = useState('connecting');
  const wsRef = useRef(null);

  useEffect(() => {
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.onopen = () => setStatus('connected');
    ws.onmessage = (event) => setData(JSON.parse(event.data));
    ws.onerror = () => setStatus('error');
    ws.onclose = () => setStatus('disconnected');

    return () => ws.close();
  }, [url]);

  const sendMessage = useCallback((message) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message));
    }
  }, []);

  return { data, status, sendMessage };
}

Dark Mode with System Preference

javascript
function useDarkMode() {
  const [theme, setTheme] = useState(() => {
    const saved = localStorage.getItem('theme');
    if (saved) return saved;
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handleChange = (e) => setTheme(e.matches ? 'dark' : 'light');
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);

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

  return [theme, toggleTheme];
}

Complete Example: Task Management App

Here's our complete task management application bringing together everything we've learned. The full implementation includes useState for local state, useEffect for localStorage persistence, useReducer for complex state management, useCallback for optimized callbacks, useMemo for expensive calculations, useRef for DOM access, and custom hooks for reusable logic.

javascript
function TaskManagerApp() {
  const initialState = {
    tasks: [],
    filter: 'all',
    sortBy: 'date'
  };

  const [state, dispatch] = useReducer(tasksReducer, initialState);
  const [tasks, setTasks] = useLocalStorage('tasks', state.tasks);
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const debouncedSearch = useDebounce(searchQuery, 300);

  const addTask = useCallback(() => {
    if (inputValue.trim()) {
      dispatch({ type: ACTIONS.ADD_TASK, payload: { text: inputValue } });
      setInputValue('');
    }
  }, [inputValue]);

  const filteredAndSortedTasks = useMemo(() => {
    let filtered = state.tasks.filter(task => {
      const matchesSearch = task.text.toLowerCase().includes(debouncedSearch.toLowerCase());
      const matchesFilter = state.filter === 'all' ||
        (state.filter === 'active' && !task.completed) ||
        (state.filter === 'completed' && task.completed);
      return matchesSearch && matchesFilter;
    });

    filtered.sort((a, b) => {
      if (state.sortBy === 'alphabetical') return a.text.localeCompare(b.text);
      return b.createdAt - a.createdAt;
    });

    return filtered;
  }, [state.tasks, state.filter, state.sortBy, debouncedSearch]);

  // ... render UI
}

Exercises for Practice

  • Add Task Priorities: Extend the task manager to include priority levels (Low, Medium, High) with sortable priorities and priority-based styling.
  • Create a useAsync Hook: Build a custom hook that handles async operations with loading and error states.
  • Add Task Categories/Tags: Implement a tagging system with multi-tag support, tag filtering, and tag statistics.
  • Build an Undo/Redo System: Implement undo/redo functionality using useReducer and useRef to track history.
  • Add Due Dates and Reminders: Extend tasks with date pickers, overdue indicators, and due date sorting.
  • Create a useMediaQuery Hook: Build a hook that tracks media query matches for responsive behavior.
  • Implement Drag and Drop: Add drag-and-drop reordering using refs for DOM manipulation.
  • Build a useOnClickOutside Hook: Create a hook that detects clicks outside an element for dropdowns and modals.

Conclusion

React Hooks have transformed how we write React applications, making our code more reusable, testable, and easier to understand. Through this comprehensive guide, we've covered core hooks (useState, useEffect, useContext, useReducer, useCallback, useMemo, useRef), custom hooks for extracting reusable logic, common pitfalls and how to avoid them, performance optimization techniques, and real-world use cases and patterns.

Key Takeaways

  • Start simple: Use useState for simple state, progress to useReducer for complex scenarios
  • Understand dependencies: The dependency array is crucial for useEffect, useMemo, and useCallback
  • Clean up effects: Always return cleanup functions from useEffect to prevent memory leaks
  • Custom hooks are powerful: Extract common logic into custom hooks for reusability
  • Optimize wisely: Don't prematurely optimize, use useMemo and useCallback only when needed
  • Follow the rules: Always call hooks at the top level and only from React functions

Next Steps

  • Practice: Build real projects using hooks
  • Read the docs: Official React documentation is excellent
  • Explore libraries: Check out React Query, SWR, and Zustand that leverage hooks
  • Contribute: Share your custom hooks with the community
  • Stay updated: React continues to evolve, follow RFC discussions

Remember: Hooks are just functions. Understanding JavaScript closures, scopes, and functional programming will make you much more effective with hooks. Keep practicing, and soon hooks will become second nature! Happy coding!

Keep reading