Codepath

React Basics of Hooks and Rules of Hooks

Overview

React Hooks were introduced in React 16.8 as a way to use state and other React features without writing class components. They revolutionized React development by enabling function components to manage state, handle side effects, and tap into React's lifecycle features.

This guide covers:

  • What React Hooks are and why they were created
  • When and how to use hooks
  • Overview of the most common built-in hooks
  • Rules of Hooks and best practices
  • Common issues and pitfalls to avoid

By the end of this guide, you'll understand the fundamentals of React Hooks and be able to implement them following best practices.

What are React Hooks?

Hooks are functions that let you "hook into" React state and lifecycle features from function components. Before hooks, developers had to use class components for state management and lifecycle methods, which often led to complex components with duplicated logic across lifecycle methods.

The "old way" of doing things (Class components)

You may find documentation or dive into a legacy codebase and see something like this. This is a class component. We are only providing this as a reference, as we do not recommend using class components.

// Before hooks (Class component)
class Counter extends React.Component {
  // the class components way of initializing state
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  
  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        {/* this.setState() is a class method, which is not recommended */}
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

The "new way" of doing things (Functional components with hooks)

While Class Components work, we should prefer using React Hooks, since they are more intuitive, easier to understand and maintain, and all newer React codebases are using hooks.

// After hooks (Function component)
function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

Why Hooks Were Created

  1. Reuse stateful logic - Hooks allow you to extract and share stateful logic between components without changing your component hierarchy
  2. Split complex components - Break down large components into smaller functions based on related pieces
  3. Use more React features without classes - Classes can be confusing with this binding, requiring understanding of JavaScript's prototype inheritance
  4. More intuitive lifecycle management - Class component lifecycle methods often contained unrelated logic, making them hard to understand and maintain

🎬 Watch: React Today and Tomorrow - The original presentation introducing Hooks by React team members

When to Use Hooks

Hooks should be used when:

  1. You need to manage local component state - Use useState to track values that change over time
  2. You need to perform side effects - Use useEffect for data fetching, subscriptions, or DOM manipulations
  3. You want to share logic between components - Create custom hooks to extract and reuse component logic
  4. You need to access context - Use useContext to consume context without nesting
  5. You want to optimize performance - Use useMemo and useCallback to avoid unnecessary calculations and re-renders

Common Built-in Hooks

useState

The useState hook allows functional components to manage state. It returns a stateful value and a function to update it.

import React, { useState } from 'react';

function Example() {
  // 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>
  );
}

For more details, see React useState Hook.

useEffect

The useEffect hook performs side effects in function components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount in React classes.

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

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

  // Similar to componentDidMount and componentDidUpdate
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
    
    // Optional return function for cleanup (like componentWillUnmount)
    return () => {
      document.title = 'React App';
    };
  }, [count]); // Only re-run if count changes

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

For more details, see React useEffect Hook.

Rules of Hooks

React relies on the order in which Hooks are called to correctly preserve state between multiple useState and useEffect calls. This requires following two critical rules:

1. Only Call Hooks at the Top Level

Never call hooks inside loops, conditions, or nested functions. React needs to call hooks in the same order each time a component renders to correctly preserve state between renders.

// ✅ Good: Hooks at the top level
function Form() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  
  // ...
}

// ❌ Bad: Hook inside a condition
function Form() {
  const [name, setName] = useState('');
  
  if (name !== '') {
    // This breaks the rule!
    useEffect(() => {
      // ...
    });
  }
  
  // ...
}

2. Only Call Hooks from React Functions

Call hooks only from:

  • React function components
  • Custom hooks (functions starting with "use")

Don't call hooks from regular JavaScript functions or class components.

// ✅ Good: Called from a React function component
function Example() {
  const [count, setCount] = useState(0);
  // ...
}

// ✅ Good: Called from a custom Hook
function useWindowSize() {
  const [size, setSize] = useState([0, 0]);
  // ...
  return size;
}

// ❌ Bad: Called from a regular function
function regularFunction() {
  const [count, setCount] = useState(0); // This is not allowed!
  // ...
}

🛠️ Tool: eslint-plugin-react-hooks automatically enforces these rules

Best Practices for Using Hooks

1. Create Custom Hooks for Reusable Logic

Extract common stateful logic into custom hooks that can be shared across components.

// Custom hook for managing form fields
function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  
  function handleChange(e) {
    setValue(e.target.value);
  }
  
  return {
    value,
    onChange: handleChange
  };
}

// Using the custom hook in multiple components
function LoginForm() {
  const username = useFormInput('');
  const password = useFormInput('');
  
  return (
    <form>
      <input type="text" {...username} />
      <input type="password" {...password} />
    </form>
  );
}

2. Keep Logic Organized in Multiple Hooks

Split complex logic into multiple hooks rather than creating one giant hook. This improves readability and makes testing easier.

function ProductPage({ productId }) {
  // Separate concerns into different hooks
  const product = useProductData(productId);
  const cart = useShoppingCart();
  const theme = useTheme();
  
  // ...
}

3. Be Careful with Dependencies Arrays

Always include all values from the component scope that change over time and are used by the effect in the dependencies array.

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  // ✅ Good: All dependencies included
  useEffect(() => {
    fetchResults(query).then(data => setResults(data));
  }, [query]); // query is included as a dependency
  
  // ...
}

4. Use Functional Updates for State

When updating state based on previous state, use the functional form of the state setter.

// ❌ May lead to stale state issues
function Counter() {
  const [count, setCount] = useState(0);
  
  function increment() {
    setCount(count + 1); // Uses the captured count value
  }
  
  // ...
}

// ✅ Safer approach using functional updates
function Counter() {
  const [count, setCount] = useState(0);
  
  function increment() {
    setCount(prevCount => prevCount + 1); // Always uses latest state
  }
  
  // ...
}

Common Issues & Misconceptions

1. Infinite Render Loops

A common mistake is creating an infinite loop by updating state in an effect without a proper dependency array.

// ❌ Infinite loop!
function BadComponent() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(count + 1); // Updates state, triggers re-render, runs effect again...
  }); // Missing dependency array
  
  return <div>{count}</div>;
}

// ✅ Fixed version
function GoodComponent() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // Only run once on mount
    setCount(prevCount => prevCount + 1);
  }, []); // Empty dependency array
  
  return <div>{count}</div>;
}

2. Hooks vs Event Handlers

A common misconception is confusing when to use hooks versus when to use event handlers.

function Form() {
  const [name, setName] = useState('');
  
  // ❌ Don't use hooks for event handling
  const handleClick = () => {
    // This is wrong!
    const [data, setData] = useState(null);
    // ...
  };
  
  // ✅ Do define event handlers as regular functions
  const handleSubmit = async (e) => {
    e.preventDefault();
    const response = await submitForm(name);
    // ...
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button type="submit">Submit</button>
    </form>
  );
}

3. Stale Closures

Functions defined in your components capture the props and state from the render they were created in. This can lead to unexpected behaviors.

function Timer() {
  const [count, setCount] = useState(0);
  
  // ❌ Problematic: This closure captures the initial count value (0)
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
      setCount(count + 1); // Will always use the initial value of count
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // Empty dependency array means this only runs on mount
  
  // ✅ Fixed: Use functional update and don't depend on count in closure
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prevCount => prevCount + 1); // Always uses the latest count
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // Empty dependency is fine now since we don't use count inside
  
  return <div>{count}</div>;
}

Resources

Fork me on GitHub