React Hooks Explained: The Ultimate Guide

15 min read

Cover Image for React Hooks Explained: The Ultimate Guide

React Hooks provide a way to use state and other React features without writing a class. They were introduced in React 16.8 and have since become a fundamental aspect of modern React development.

Hooks are essentially functions that provide a powerful and flexible way to manage state, handle side effects, and optimize performance. They also help maintain a clean and declarative coding style. In this comprehensive guide, we will explore the most essential React Hooks, detailing when and how to use them, along with practical examples to help you integrate them into your projects.

Whether you are new to React or looking to deepen your understanding of Hooks, this guide will equip you with the knowledge needed to harness the full potential of React Hooks. Read on!

1. useState

In React, useState is a special function that lets you add state to functional components. It provides a way to declare and manage state variables directly within a function component. It's important to note that each call to useState() can only declare one state variable. Introduced in version 16.8, it simplifies state management in functional components.

Functional components are preferred due to their concise code. useState simplifies state management. Also, with useState, you can handle state without converting your component into a class.

Importing the useState Hook

To import the useState hook, write the following code at the top level of your component:

import { useState } from "react";

Structure of useState Hook

This hook takes some initial state and returns two values: the current state and a function to update that state. The value passed to useState will be treated as the default value.

const [var, setVar] = useState(initialValue);

How useState Works

  • useState() creates a new cell in the functional component’s memory object.

  • New state values are stored in this cell during renders.

  • The stack pointer points to the latest cell after each render.

  • Deliberate user refresh triggers stack dump and fresh allocation.

  • The memory cell preserves state between renders, ensuring persistence.

Declaring a State in React with useState

To use the useState hook, you must first import it from React, or you will have to append it like React.useState() each time you create a state. The useState hook takes the initial value of the state variable as an argument. This value can be of any data type, such as a string, number, object, array, and more.

Basic Example:

import React, { useState } from 'react';

const App = () => {
    const number = useState(0);
    const string = useState('');
    const object = React.useState({});
    const array = React.useState([]);

    return (
        // ...
    );
};

Using the State Value in JSX:

const App = () => {
    const [count, setCount] = useState(0);
    return (
        <div>
            <p>{count}</p>
        </div>
    );
};

Updating State with useState

The second value returned by useState is a function used to update the state. This can be called anything, but it's best practice to use the variable name with a prefix of set.

Example:

const [state, setState] = useState(initialValue);
const [count, setCount] = useState(initialCount);
const [anything, setAnything] = useState(initialAnything);

Incrementing a Counter:

const App = () => {
    const [count, setCount] = useState(0);

    const incrementCount = () => {
        setCount(count + 1);
    };

    return (
        <div>
            <p>{count}</p>
            <button type="button" onClick={incrementCount}>
                Increment Count
            </button>
        </div>
    );
};

Inline Update Function:

<button type="button" onClick={() => setCount(count + 1)}>
    Increment Count
</button>

Using Multiple State Variables

Creating individual states for data that can be combined into one state isn't advisable when working with real-life data. For example, if you want to create a state for user data such as name, age, and hobby:

Separate States:

const App = () => {
    const [name, setName] = useState("John Doe");
    const [age, setAge] = useState(20);
    const [hobby, setHobby] = useState("reading");

    return (
        // ...
    );
};

Combined State Object:

const App = () => {
    const [userDetails, setUserDetails] = useState({
        name: 'John Doe',
        age: 20,
        hobby: 'Reading',
    });

    return (
        <div>
            <h1>{userDetails.name}</h1>
            <p>
                {userDetails.age} || {userDetails.hobby}
            </p>
        </div>
    );
};

Destructuring State Object:

const App = () => {
    const [{ name, age, hobby }, setUserDetails] = useState({
        name: 'John Doe',
        age: 20,
        hobby: 'Reading',
    });

    return (
        <div>
            <h1>{name}</h1>
            <p>
                {age} || {hobby}
            </p>
        </div>
    );
};

Using an Object as a State Variable

When working with real-life data, you often deal with objects. To update a particular field in an object, use the spread operator to ensure other fields remain unchanged.

Example with Nested Objects:

const App = () => {
    const [userDetails, setUserDetails] = useState({
        userName: {
            firstName: 'John',
            lastName: 'Doe',
        },
        age: 20,
        hobby: 'Reading',
    });

    const changeName = () => {
        setUserDetails({
            ...userDetails,
            userName: {
                ...userDetails.userName,
                firstName: 'Jane',
            },
        });
    };

    return (
        <div>
            <h1>
                Hello {userDetails.userName.firstName} {userDetails.userName.lastName},
            </h1>
            <p>
                {userDetails.age} || {userDetails.hobby}
            </p>
            <button onClick={changeName}>Change Name</button>
        </div>
    );
};

2. useEffect

The useEffect hook allows you to perform side effects in your components. These side effects can include data fetching, directly updating the DOM, and setting up timers. The useEffect hook accepts two arguments: a function containing the side effect logic and an optional dependency array that determines when the effect should be executed.

useEffect(() => {
  // Side effect logic
}, [dependencies]);
  • Effect Function: This is the main function where you place your side effect logic.

  • Dependency Array: This optional array allows you to control when the effect should run. If it's empty, the effect runs only once after the initial render. If it includes dependencies, the effect runs whenever any of the dependencies change.

Basic Usage of useEffect

Here are a few usage examples of useEffect:

Fetching Data

Here's an example of using useEffect to fetch data from an API when the component mounts:

javascriptCopy codeimport { useState, useEffect } from "react";

function DataFetcher() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch("https://api.example.com/data")
      .then(response => response.json())
      .then(data => setData(data))
      .catch(error => console.error("Error fetching data:", error));
  }, []); // Empty array ensures this runs only once

  return (
    <div>
      {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : <p>Loading...</p>}
    </div>
  );
}

export default DataFetcher;

In this example, the useEffect hook fetches data from an API endpoint when the component first renders. The empty dependency array ensures that this effect runs only once.

Dependency Array

The dependency array in useEffect controls when the effect should run. Here are three common scenarios:

  1. No Dependency Passed:

     useEffect(() => {
       console.log("Runs on every render");
     });
    
  2. Empty Array:

     useEffect(() => {
       console.log("Runs only on the first render");
     }, []);
    
  3. Props or State Values:

     useEffect(() => {
       console.log("Runs on the first render and whenever prop or state changes");
     }, [prop, state]);
    

Timer with Dependencies

Here's an example of a timer that updates every second, demonstrating how to use dependencies:

import { useState, useEffect } from "react";

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

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

    return () => clearInterval(interval); // Cleanup on unmount
  }, []); // Empty array means this effect runs once

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

export default Timer;

In this example, the timer increments the seconds state every second. The cleanup function ensures that the interval is cleared when the component unmounts, preventing memory leaks.

Side Effects in React:
Side effects are not predictable because they interact with the outside world. Examples include:

  • Making a request to an API for data

  • Interacting with browser APIs (like document or window)

  • Using timing functions like setTimeout or setInterval

Side effects are essential for most applications, as they allow components to fetch data, update the DOM, and perform other necessary operations. The useEffect hook provides a way to handle these side effects cleanly and efficiently within functional components.

3. useContext

useContext lets you subscribe to React context without introducing nesting. It allows components to access and subscribe to data from a central source, eliminating the need for prop drilling (passing data through multiple levels of nested components).

Creating the Context

First, you need to create a context using React.createContext(). This function returns a context object that you can use throughout your component tree.

import React from 'react';

const UserContext = React.createContext();

Providing the Context

Next, you need to provide the context's value to the components that require access to it. You do this by wrapping your component tree with the context provider and passing the value you want to share.

import React, { useState } from 'react';

function App() {
  const [userName, setUserName] = useState('Guest');

  return (
    <UserContext.Provider value={userName}>
      <UserProfile />
    </UserContext.Provider>
  );
}

export default App;

In this example, the userName state is provided to all components within the UserContext.Provider.

Consuming the Context

Finally, you can consume the context in any component that needs access to the shared data using the useContext hook.

import React, { useContext } from 'react';
import UserContext from './UserContext';

function UserProfile() {
  const userName = useContext(UserContext);

  return <div>Welcome, {userName}!</div>;
}

export default UserProfile;

Here, the UserProfile component uses the useContext hook to access the userName provided by UserContext.Provider.

When Do You Need Context?

Context is a powerful tool but should be used judiciously. Here are a few scenarios where context is particularly useful:

  1. Sharing Global Data: When multiple components need access to the same data, such as user authentication, theme settings, or language preferences.

  2. Avoiding Prop Drilling: To avoid passing props through multiple levels of nested components, which can make your code harder to maintain.

  3. Managing State: For centralizing state management in your app, especially for complex state used by many components.

Use Case: Global User Name

To illustrate the benefits of using useContext, let's explore a practical example where the user's name is displayed in various parts of the UI and can be updated without prop drilling.

Context to the Rescue

First, create a context for the user's name and make it available globally.

// UserContext.js
import React, { createContext, useContext, useState } from 'react';

const UserContext = createContext();

export function UserProvider({ children }) {
  const [userName, setUserName] = useState('Guest');

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

export function useUser() {
  return useContext(UserContext);
}

In this example, UserProvider component wraps its children with UserContext.Provider, providing both userName and setUserName.

When Context Changes

Any component within the app can access and update the user's name without prop drilling.

import React from 'react';
import { useUser } from './UserContext';

function UserProfile() {
  const { userName, setUserName } = useUser();

  const handleChangeName = () => {
    const newName = prompt('Enter your name:');
    if (newName) {
      setUserName(newName);
    }
  };

  return (
    <div>
      <p>Welcome, {userName}!</p>
      <button onClick={handleChangeName}>Change Name</button>
    </div>
  );
}

export default UserProfile;

When setUserName is called, it updates the userName in the context, causing all components that consume the context to re-render with the updated name.

Full Example

Here's a complete example demonstrating how to use useContext to share user information across components.

// UserContext.js
import React, { createContext, useContext, useState } from 'react';

const UserContext = createContext();

export function UserProvider({ children }) {
  const [userName, setUserName] = useState('Guest');

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

export function useUser() {
  return useContext(UserContext);
}

// App.js
import React from 'react';
import { UserProvider } from './UserContext';
import UserProfile from './UserProfile';

function App() {
  return (
    <UserProvider>
      <UserProfile />
    </UserProvider>
  );
}

export default App;

// UserProfile.js
import React from 'react';
import { useUser } from './UserContext';

function UserProfile() {
  const { userName, setUserName } = useUser();

  const handleChangeName = () => {
    const newName = prompt('Enter your name:');
    if (newName) {
      setUserName(newName);
    }
  };

  return (
    <div>
      <p>Welcome, {userName}!</p>
      <button onClick={handleChangeName}>Change Name</button>
    </div>
  );
}

export default UserProfile;

4. useReducer

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. You can use useReducer when managing more complex state logic, particularly when state transitions are based on more than one value.

Usage:

  1. Basic Usage:

     import { useReducer } from 'react';
    
     function reducer(state, action) {
       switch (action.type) {
         case 'increment':
           return { count: state.count + 1 };
         case 'decrement':
           return { count: state.count - 1 };
         default:
           throw new Error();
       }
     }
    
     function Counter() {
       const [state, dispatch] = useReducer(reducer, { count: 0 });
    
       return (
         <div>
           <p>Count: {state.count}</p>
           <button onClick={() => dispatch({ type: 'increment' })}>+</button>
           <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
         </div>
       );
     }
    

5. useRef

useRef returns a mutable ref object whose .current property is initialized with the passed argument. It can be used to access DOM nodes or keep a mutable value that does not cause re-renders when updated.

When to Use: Use useRef when you need to directly access a DOM element or when you need a persistent, mutable value that doesn't trigger a re-render.

Usage:

  1. Accessing DOM Nodes:

     import { useRef } from 'react';
    
     function TextInputWithFocusButton() {
       const inputEl = useRef(null);
       const onButtonClick = () => {
         inputEl.current.focus();
       };
    
       return (
         <>
           <input ref={inputEl} type="text" />
           <button onClick={onButtonClick}>Focus the input</button>
         </>
       );
     }
    
  2. Keeping a Mutable Value:

     import { useRef } from 'react';
    
     function Stopwatch() {
       const timerRef = useRef(0);
    
       const startTimer = () => {
         timerRef.current = setInterval(() => {
           console.log('Tick');
         }, 1000);
       };
    
       const stopTimer = () => {
         clearInterval(timerRef.current);
       };
    
       return (
         <div>
           <button onClick={startTimer}>Start</button>
           <button onClick={stopTimer}>Stop</button>
         </div>
       );
     }
    

6. useMemo

useMemo returns a memoized value. It is useful for optimizing performance by memoizing expensive calculations.

When to Use: Use useMemo when you have a computation that is expensive and you want to avoid recalculating it on every render.

Usage:

  1. Basic Usage:

     import { useMemo } from 'react';
    
     function CalculationComponent({ a, b }) {
       const theResult = useMemo(() => {
         return a + b; // Expensive computation here
       }, [a, b]);
    
       return <div>The result is {theResult}</div>;
     }
    

7. useCallback

useCallback returns a memoized callback. It is useful for passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders. You can use useCallback when you need to pass a function as a prop to a child component and want to prevent unnecessary re-renders of that child component.

  1. Basic Usage:

     import { useCallback } from 'react';
    
     function ParentComponent() {
       const [count, setCount] = useState(0);
    
       const handleClick = useCallback(() => {
         setCount(prevCount => prevCount + 1);
       }, []); // The empty array ensures the function is only created once
    
       return <ChildComponent onClick={handleClick} />;
     }
    
     function ChildComponent({ onClick }) {
       return <button onClick={onClick}>Click me</button>;
     }
    

8. useImperativeHandle

useImperativeHandle customizes the instance value that is exposed when using ref in parent components. It can be used to expose imperative methods to parent components. You can use useImperativeHandle when you need to control the ref value passed to a child component from a parent component.

  1. Basic Usage:

     import { useImperativeHandle, forwardRef, useRef } from 'react';
    
     function ChildComponent(props, ref) {
       useImperativeHandle(ref, () => ({
         focus: () => {
           inputRef.current.focus();
         },
       }));
    
       const inputRef = useRef();
       return <input ref={inputRef} />;
     }
    
     ChildComponent = forwardRef(ChildComponent);
    
     function ParentComponent() {
       const inputRef = useRef();
    
       return (
         <div>
           <ChildComponent ref={inputRef} />
           <button onClick={() => inputRef.current.focus()}>Focus the input</button>
         </div>
       );
     }
    

9. useLayoutEffect

useLayoutEffect fires synchronously after all DOM mutations. Use it when you need to read layout from the DOM and synchronously re-render. You can use the useLayoutEffect when you need to perform measurements or mutations that should block the browser's painting.

  1. Basic Usage:

     import { useLayoutEffect, useRef } from 'react';
    
     function LayoutEffectExample() {
       const divRef = useRef();
    
       useLayoutEffect(() => {
         const { height } = divRef.current.getBoundingClientRect();
         console.log('Height:', height);
       });
    
       return <div ref={divRef}>Hello, World!</div>;
     }
    

10. useDebugValue

useDebugValue is used to display a label for custom hooks in React DevTools. You can use useDebugValue when you are developing custom hooks and want to provide a useful label for them in React DevTools.

Here is an example on how to use useDebugValue:

  1.  import { useDebugValue, useState, useEffect } from 'react';
    
     function useFriendStatus(friendID) {
       const [isOnline, setIsOnline] = useState(null);
    
       useEffect(() => {
         const handleStatusChange = (status) => {
           setIsOnline(status.isOnline);
         };
    
         // Assume 'ChatAPI' is a module that manages the subscription
         ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
         return () => {
           ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
         };
       }, [friendID]);
    
       useDebugValue(isOnline ? 'Online' : 'Offline');
       return isOnline;
     }
    

11. useId

useId is used to generate unique IDs that can be used for accessibility attributes. You can use useId when you need a unique identifier for elements like form inputs to link them with their labels.

  1. Basic Usage:

     import { useId } from 'react';
    
     function Form() {
       const id = useId();
    
       return (
         <div>
           <label htmlFor={id}>Name: </label>
           <input id={id} type="text" />
         </div>
       );
     }
    

12. useDeferredValue

useDeferredValue lets you defer a value until the browser has had a chance to paint, improving performance. You can use useDeferredValue when you have a state that is expensive to render and you want to defer its update to improve performance.

Here is an example of how to use useDeferredValue:

import { useState, useDeferredValue } from 'react';

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);

  const results = performExpensiveSearch(deferredQuery);

  return <div>{results}</div>;
}

function performExpensiveSearch(query) {
  // Perform the search and return the results
}

13. useSyncExternalStore

useSyncExternalStore lets you subscribe to an external store and update the state in sync with it. Use useSyncExternalStore when you need to subscribe to an external data source and update the state in your component in sync with it.

  1. Basic Usage:

     import { useSyncExternalStore } from 'react';
    
     function useWindowSize() {
       const getSnapshot = () => ({
         width: window.innerWidth,
         height: window.innerHeight,
       });
    
       const subscribe = (callback) => {
         window.addEventListener('resize', callback);
         return () => window.removeEventListener('resize', callback);
       };
    
       return useSyncExternalStore(subscribe, getSnapshot);
     }
    
     function WindowSizeComponent() {
       const size = useWindowSize();
       return (
         <div>
           Width: {size.width}, Height: {size.height}
         </div>
       );
     }
    

14. useTransition

useTransition lets you defer state updates that are not urgent, keeping the app responsive during heavy computations. You can use useTransition when you have non-urgent updates that can be deferred to keep the UI responsive.

import { useState, useTransition } from 'react';

function App() {
  const [isPending, startTransition] = useTransition();
  const [value, setValue] = useState('');

  const handleChange = (e) => {
    startTransition(() => {
      setValue(e.target.value);
    });
  };

  return (
    <div>
      <input type="text" onChange={handleChange} />
      {isPending ? 'Loading...' : <div>{value}</div>}
    </div>
  );
}

Conclusion

React Hooks provide a powerful way to build functional components with all the features you need for managing state, side effects, context, refs, and more. Understanding and effectively using these hooks will significantly enhance your ability to build complex and efficient React applications. This guide serves as a comprehensive reference to help you leverage the full potential of React Hooks.