javascriptroom guide

How to Implement React Hooks in Your Projects

Since their introduction in React 16.8, Hooks have revolutionized how developers write React components. They allow you to use state, lifecycle methods, and other React features *without writing class components*. This shift has made code more concise, reusable, and easier to maintain. Before Hooks, functional components were limited to being "stateless," forcing developers to use class components (or workarounds like Higher-Order Components/HOCs and render props) for stateful logic. Hooks eliminate this complexity by letting you "hook into" React’s core features directly from functional components. In this guide, we’ll dive deep into how to implement React Hooks in your projects. We’ll cover core Hooks, custom Hooks, best practices, and common pitfalls—equipping you with everything you need to start using Hooks effectively.

Table of Contents

  1. What Are React Hooks?
  2. Prerequisites
  3. Core React Hooks
  4. Custom Hooks: Reusing Stateful Logic
  5. Best Practices for Using Hooks
  6. Common Pitfalls to Avoid
  7. Conclusion
  8. References

What Are React Hooks?

React Hooks are functions that let you “hook into” React state and lifecycle features from functional components. They were designed to solve three main problems with class components:

  • Reusing stateful logic: Class components made it hard to share logic between components (e.g., HOCs and render props led to “wrapper hell”).
  • Complex components: Classes often became bloated with lifecycle methods (e.g., componentDidMount, componentDidUpdate), mixing unrelated logic.
  • Confusing this: The this keyword in classes is error-prone and unintuitive for many developers.

Hooks provide a cleaner, more modular way to write React components. They work with functional components and let you split logic into reusable, testable functions.

Rules of Hooks

To use Hooks correctly, you must follow two core rules (enforced by the ESLint plugin eslint-plugin-react-hooks):

  1. Only call Hooks at the top level: Don’t call Hooks inside loops, conditions, or nested functions. This ensures Hooks are called in the same order every render.
  2. Only call Hooks from React functions: Call them from functional components or custom Hooks (never from regular JavaScript functions).

Prerequisites

Before diving into Hooks, ensure you have:

  • Basic knowledge of React (components, props, state).
  • Familiarity with functional components.
  • Understanding of ES6 features (arrow functions, destructuring, const/let).
  • React 16.8 or later installed in your project.

Core React Hooks

React provides several built-in Hooks. Let’s explore the most commonly used ones, their use cases, and implementation examples.

useState: Managing State

useState is the most basic Hook. It lets you add state to functional components.

How It Works:

  • Takes an initial state value as input.
  • Returns an array with two elements: the current state, and a function to update it.

Example: Counter Component

import { useState } from 'react';

function Counter() {
  // Declare a state variable "count" with initial value 0
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

Breakdown:

  • useState(0) initializes count to 0.
  • setCount is the updater function. When called, it re-renders the component with the new count.
  • Unlike class setState, useState updaters replace state (they don’t merge it). For objects/arrays, spread the previous state:
    const [user, setUser] = useState({ name: 'John', age: 30 });
    setUser(prevUser => ({ ...prevUser, age: prevUser.age + 1 })); // Merge updates

useEffect: Handling Side Effects

useEffect lets you perform side effects in functional components (e.g., data fetching, subscriptions, DOM manipulations). It replaces class lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.

How It Works:

  • Takes a effect function and an optional dependency array.
  • The effect function runs after render.
  • The dependency array controls when the effect runs:
    • No array: Runs after every render.
    • Empty array ([]): Runs once after mount (like componentDidMount).
    • Array with values: Runs only when those values change (like componentDidUpdate for those props/state).

Example 1: Data Fetching on Mount

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // Effect function: Fetch user data
    const fetchUser = async () => {
      setLoading(true);
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (error) {
        console.error('Error fetching user:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // Cleanup function (optional): Runs before the component unmounts or re-runs the effect
    return () => {
      // Cancel pending requests, clear subscriptions, etc.
      console.log('Cleanup: UserProfile unmounting or userId changed');
    };
  }, [userId]); // Re-run effect only if userId changes

  if (loading) return <p>Loading...</p>;
  if (!user) return <p>User not found</p>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Key Notes:

  • The cleanup function prevents memory leaks (e.g., unsubscribing from events).
  • Always include all variables used in the effect in the dependency array (React DevTools can warn you about missing dependencies).

useContext: Consuming Context

useContext lets you consume context in a functional component without nesting Context.Consumer.

How It Works:

  • Takes a context object (created with React.createContext).
  • Returns the current context value for that context.

Example: Theme Context

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

// 1. Create a context
const ThemeContext = createContext();

// 2. Provide context at the top level
function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Navbar />
      <Content />
    </ThemeContext.Provider>
  );
}

// 3. Consume context in child components
function Navbar() {
  const { theme } = useContext(ThemeContext);
  return <nav style={{ background: theme === 'light' ? '#fff' : '#333' }}>Navbar</nav>;
}

function Content() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </div>
  );
}

Why This Works:

  • useContext avoids “prop drilling” (passing props through intermediate components).
  • It re-renders the component whenever the context value changes.

useReducer: Complex State Logic

useReducer is an alternative to useState for state logic that involves multiple sub-values or complex transitions (e.g., forms, todo lists). It uses a reducer function to manage state, inspired by Redux.

How It Works:

  • Takes a reducer function and an initialState.
  • Returns the current state and a dispatch function to trigger state updates.

Example: Todo App

import { useReducer } from 'react';

// Reducer function: Takes current state and action, returns new state
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.text, completed: false }];
    case 'TOGGLE_TODO':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case 'DELETE_TODO':
      return state.filter(todo => todo.id !== action.id);
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, []); // Initial state: empty array
  const [text, setText] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    dispatch({ type: 'ADD_TODO', text }); // Dispatch "ADD_TODO" action
    setText('');
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Add todo"
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map(todo => (
          <li
            key={todo.id}
            style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
            onClick={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })}
          >
            {todo.text}
            <button onClick={() => dispatch({ type: 'DELETE_TODO', id: todo.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Benefits of useReducer:

  • Centralizes state logic, making it easier to test and debug.
  • Predictable state transitions via actions.

useRef: Persisting Values & DOM Access

useRef creates a mutable ref object that persists for the lifetime of the component. It has two main use cases:

1. Accessing DOM Elements

import { useRef, useEffect } from 'react';

function TextInput() {
  const inputRef = useRef(null); // Initialize with null

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

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

2. Persisting Values Between Renders

useRef values don’t trigger re-renders when updated, unlike state.

import { useRef, useState } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null); // Persist the interval ID

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

  const stopTimer = () => {
    clearInterval(intervalRef.current); // Access the persisted ID
  };

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

useCallback: Memoizing Functions

useCallback memoizes functions, preventing unnecessary re-creation on every render. This is useful when passing functions to child components that rely on reference equality (e.g., React.memo components).

Example: Optimizing Child Component Re-renders

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

// Memoized child component: Only re-renders if props change
const ExpensiveComponent = memo(({ onButtonClick }) => {
  console.log('ExpensiveComponent re-rendered');
  return <button onClick={onButtonClick}>Click me</button>;
});

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

  // Memoize the function: Only re-create if dependencies change (none here)
  const handleClick = useCallback(() => {
    console.log('Button clicked');
  }, []); // Empty dependency array: function never re-creates

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

Why This Works:

  • Without useCallback, handleClick is re-created on every render, causing ExpensiveComponent to re-render (since onButtonClick prop changes).
  • With useCallback, handleClick is memoized, so ExpensiveComponent only renders once.

useMemo: Memoizing Expensive Calculations

useMemo memoizes the result of expensive calculations, re-computing only when dependencies change.

Example: Avoiding Recomputing Expensive Values

import { useState, useMemo } from 'react';

function ExpensiveCalculation({ numbers }) {
  // Memoize the result: Only re-calculate if "numbers" changes
  const sum = useMemo(() => {
    console.log('Calculating sum...');
    return numbers.reduce((acc, n) => acc + n, 0);
  }, [numbers]); // Re-run only if "numbers" changes

  return <p>Sum: {sum}</p>;
}

function ParentComponent() {
  const [numbers] = useState([1, 2, 3, 4, 5]);
  const [count, setCount] = useState(0);

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

Key Note:

  • Use useMemo only for expensive calculations (e.g., sorting large arrays). For trivial logic, it adds unnecessary overhead.

Custom Hooks: Reusing Stateful Logic

Custom Hooks let you extract and reuse stateful logic across multiple components. They are regular JavaScript functions named with the prefix use (to enforce Hooks rules).

Example: useLocalStorage Hook

Let’s create a Hook to sync state with localStorage:

import { useState, useEffect } from 'react';

// Custom Hook: Persists state to localStorage
function useLocalStorage(key, initialValue) {
  // Initialize state with localStorage value (or initialValue)
  const [value, setValue] = useState(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : initialValue;
  });

  // Update localStorage when value changes
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage in a component
function UserSettings() {
  const [username, setUsername] = useLocalStorage('username', '');

  return (
    <div>
      <input
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="Enter username"
      />
      <p>Hello, {username || 'Guest'}!</p>
    </div>
  );
}

Why This Works:

  • useLocalStorage encapsulates the logic for reading from/writing to localStorage.
  • It can be reused in any component that needs persistent state.

Best Practices for Using Hooks

  1. Follow the Rules of Hooks: Use the ESLint plugin to enforce them.
  2. Keep Hooks Focused: Each Hook should handle one piece of logic (e.g., separate useState calls for unrelated state).
  3. Avoid Over-optimizing: Use useCallback/useMemo only when necessary (they add overhead).
  4. Name Custom Hooks with use: This makes them recognizable as Hooks and ensures linter support.
  5. Test Custom Hooks: Use tools like @testing-library/react-hooks to test logic in isolation.

Common Pitfalls to Avoid

1. Stale Closures

Happens when an effect/function references an outdated state/props value.

Example:

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

  useEffect(() => {
    const interval = setInterval(() => {
      console.log('Count:', count); // Always logs 0 (stale closure)
    }, 1000);
    return () => clearInterval(interval);
  }, []); // Missing "count" in dependencies

  return <button onClick={() => setCount(count + 1)}>Increment</button>;
}

Fix: Add count to the dependency array:

useEffect(() => {
  const interval = setInterval(() => {
    console.log('Count:', count);
  }, 1000);
  return () => clearInterval(interval);
}, [count]); // Now re-runs when "count" changes

2. Missing Dependencies in useEffect

Always include all variables used in the effect in the dependency array (React DevTools will warn you).

3. Overusing State

Don’t store derived values in state—compute them during render.

Bad:

const [todos, setTodos] = useState([]);
const [completedTodos, setCompletedTodos] = useState([]); // Derived value

useEffect(() => {
  setCompletedTodos(todos.filter(todo => todo.completed));
}, [todos]);

Good:

const [todos, setTodos] = useState([]);
const completedTodos = todos.filter(todo => todo.completed); // Compute during render

4. Incorrect Cleanup in useEffect

Cleanup functions must cancel subscriptions/requests to prevent memory leaks.

Example: Canceling a fetch request:

useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  const fetchData = async () => {
    try {
      const response = await fetch('https://api.example.com/data', { signal });
      // ...
    } catch (error) {
      if (error.name !== 'AbortError') console.error(error);
    }
  };

  fetchData();
  return () => controller.abort(); // Cancel request on unmount/update
}, []);

Conclusion

React Hooks have transformed how we write React components, making stateful logic more reusable, code cleaner, and components easier to maintain. By mastering core Hooks like useState, useEffect, and useContext, and learning to create custom Hooks, you can build powerful, modular React applications.

Remember to follow the Rules of Hooks, avoid common pitfalls like stale closures, and optimize only when necessary. With practice, Hooks will become an indispensable part of your React toolkit.

References