❮ Back to Journal

React Hooks Explained for Beginners: A Practical Guide

Introduction

If you’re learning React, you’ve probably heard about Hooks. Introduced in React 16.8, Hooks revolutionized how we write React components by allowing us to use state and other React features without writing classes. In this guide, I’ll explain React Hooks in a beginner-friendly way with practical examples you can start using in your projects right away.

What Are React Hooks and Why Should You Use Them?

React Hooks are functions that let you “hook into” React state and lifecycle features from function components. Before Hooks, you had to use class components for these features.

Benefits of using Hooks:

  • Write cleaner, more concise code
  • Reuse stateful logic between components
  • Organize related code together (instead of splitting it across lifecycle methods)
  • Avoid the confusion of this keyword in JavaScript classes

The Most Important Hooks

Let’s explore the most commonly used Hooks with practical examples.

useState: Managing Component State

The useState Hook lets you add state to function components.

Basic Example

import React, { useState } from 'react';

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

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

Breaking this down:

  1. We import the useState Hook from React
  2. Inside our component, we call useState(0) to create a state variable (count) with an initial value of 0
  3. useState returns an array with two items: the current state value and a function to update it
  4. We use array destructuring to assign names to these items: [count, setCount]
  5. When the button is clicked, we call setCount with the new value

Managing Multiple State Values

You can call useState multiple times in a component:

function UserForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');

  return (
    <form>
      <input
        type="text"
        value={firstName}
        onChange={(e) => setFirstName(e.target.value)}
        placeholder="First name"
      />
      <input
        type="text"
        value={lastName}
        onChange={(e) => setLastName(e.target.value)}
        placeholder="Last name"
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
    </form>
  );
}

Using Objects with useState

For related state values, you can use an object:

function UserForm() {
  const [user, setUser] = useState({
    firstName: '',
    lastName: '',
    email: ''
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setUser({
      ...user, // Important: spread the existing user properties
      [name]: value // Update only the changed field
    });
  };

  return (
    <form>
      <input
        type="text"
        name="firstName"
        value={user.firstName}
        onChange={handleChange}
        placeholder="First name"
      />
      <input
        type="text"
        name="lastName"
        value={user.lastName}
        onChange={handleChange}
        placeholder="Last name"
      />
      <input
        type="email"
        name="email"
        value={user.email}
        onChange={handleChange}
        placeholder="Email"
      />
    </form>
  );
}

Note: When updating an object with useState, you must create a new object that includes all existing properties (using the spread operator ...) plus your changes. React won’t automatically merge objects like it does with this.setState() in class components.

useEffect: Performing Side Effects

The useEffect Hook lets you perform side effects in function components. Side effects include data fetching, subscriptions, manual DOM manipulations, and more.

Basic Example

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

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

  // This runs after every render
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

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

In this example, useEffect updates the document title after every render, reflecting the current count.

Controlling When Effects Run

You can control when effects run by providing a dependency array:

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

  useEffect(() => {
    // This effect runs when the component mounts
    // and whenever userId changes
    setLoading(true);

    fetch(`https://api.example.com/users/${userId}`)
      .then(response => response.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(error => {
        console.error("Error fetching user:", error);
        setLoading(false);
      });
  }, [userId]); // Only re-run if userId changes

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

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

The dependency array [userId] tells React to only re-run the effect when userId changes.

Cleaning Up Effects

Some effects need cleanup, like subscriptions or timers:

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    // Return a cleanup function
    return () => {
      clearInterval(intervalId);
    };
  }, []); // Empty array means "run only on mount and unmount"

  return <div>Seconds: {seconds}</div>;
}

The cleanup function runs when the component unmounts or before the effect runs again.

useContext: Accessing Context

The useContext Hook provides a way to pass data through the component tree without manually passing props down at every level.

Setting Up Context

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

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

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

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

  // The value prop contains what we want to expose to consumers
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

Using Context with useContext

function ThemedButton() {
  // Get values from context
  const { theme, toggleTheme } = useContext(ThemeContext);

  return (
    <button
      onClick={toggleTheme}
      style={{
        backgroundColor: theme === 'light' ? '#fff' : '#333',
        color: theme === 'light' ? '#333' : '#fff',
        border: '1px solid',
        padding: '8px 16px',
      }}
    >
      Toggle Theme
    </button>
  );
}

// Using the provider in your app
function App() {
  return (
    <ThemeProvider>
      <div className="app">
        <h1>Context Example</h1>
        <ThemedButton />
      </div>
    </ThemeProvider>
  );
}

This allows ThemedButton to access the theme context without passing props through intermediate components.

Creating Custom Hooks

One of the most powerful features of Hooks is the ability to create your own custom Hooks to extract and reuse stateful logic.

Example: useLocalStorage

Let’s create a custom Hook that syncs state with localStorage:

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // Initialize state with value from localStorage or 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;
    }
  });

  // Update localStorage when state changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

Using the custom Hook:

function SavedNotes() {
  const [notes, setNotes] = useLocalStorage('notes', []);
  const [currentNote, setCurrentNote] = useLocalStorage('currentNote', '');

  const addNote = () => {
    if (currentNote.trim()) {
      setNotes([...notes, currentNote]);
      setCurrentNote('');
    }
  };

  return (
    <div>
      <h2>Saved Notes</h2>
      <textarea
        value={currentNote}
        onChange={(e) => setCurrentNote(e.target.value)}
        placeholder="Write a note..."
      />
      <button onClick={addNote}>Save Note</button>

      <ul>
        {notes.map((note, index) => (
          <li key={index}>{note}</li>
        ))}
      </ul>
    </div>
  );
}

This custom Hook encapsulates all the logic for reading from and writing to localStorage, making it reusable across components.

Example: useFetch

Here’s another custom Hook for data fetching:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const abortController = new AbortController();

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, { signal: abortController.signal });

        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }

        const result = await response.json();
        setData(result);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setData(null);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

    // Cleanup: abort fetch if component unmounts or url changes
    return () => abortController.abort();
  }, [url]);

  return { data, loading, error };
}

Using the custom Hook:

function UserList() {
  const { data, loading, error } = useFetch('https://api.example.com/users');

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!data) return <div>No data</div>;

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

Common Hooks Mistakes and How to Avoid Them

Mistake 1: Missing Dependencies in useEffect

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

  useEffect(() => {
    fetchResults(query).then(data => setResults(data));
  }, []); // Missing 'query' in dependencies

  // ...
}

// ✅ Correct: include all dependencies
function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(data => setResults(data));
  }, [query]); // 'query' included in dependencies

  // ...
}

Mistake 2: Creating Functions Inside useEffect

// ❌ Inefficient: creates a new function on every render
function UserProfile({ userId }) {
  useEffect(() => {
    // This function is recreated on every render
    const fetchUser = async () => {
      const response = await fetch(`/api/users/${userId}`);
      // ...
    };

    fetchUser();
  }, [userId]);

  // ...
}

// ✅ Better: use useCallback for functions needed in effects
function UserProfile({ userId }) {
  const fetchUser = useCallback(async () => {
    const response = await fetch(`/api/users/${userId}`);
    // ...
  }, [userId]);

  useEffect(() => {
    fetchUser();
  }, [fetchUser]); // fetchUser is stable between renders if userId doesn't change

  // ...
}

Mistake 3: Not Using Functional Updates

// ❌ Potential issue: uses stale state
function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    // This might use a stale value of count
    setCount(count + 1);
  };

  // ...
}

// ✅ Better: use functional updates
function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    // This always uses the latest state
    setCount(prevCount => prevCount + 1);
  };

  // ...
}

Rules of Hooks

To use Hooks correctly, follow these two rules:

  1. Only call Hooks at the top level - Don’t call Hooks inside loops, conditions, or nested functions.
// ❌ Wrong: Hook inside a condition
function Component() {
  if (someCondition) {
    useEffect(() => {
      // This breaks the rules
    });
  }
}

// ✅ Correct: Condition inside the Hook
function Component() {
  useEffect(() => {
    if (someCondition) {
      // This is fine
    }
  });
}
  1. Only call Hooks from React function components or custom Hooks - Don’t call Hooks from regular JavaScript functions.

Conclusion

React Hooks provide a more intuitive way to work with state and side effects in function components. By understanding useState, useEffect, and useContext, you have the foundation to build powerful React applications. Custom Hooks allow you to extract and reuse logic, making your code more modular and maintainable.

Remember these key points:

  • Use useState to add state to function components
  • Use useEffect for side effects like data fetching and DOM manipulation
  • Use useContext to consume context without prop drilling
  • Create custom Hooks to reuse stateful logic across components
  • Follow the Rules of Hooks for correct behavior

With these fundamentals, you’re well on your way to mastering React Hooks and building more efficient React applications.

Further Resources