What is a React hook? Everything you need to know

16 Min Read • Sep 4, 2024

Author Image

Gabriel Arghire

Full Stack Developer

Author Image

Take into account before reading

I have been coding in React for 2 years now. I summarized my experience with hooks in this 10 minute article and wrote it with my own words. Skipping sentences will shorten your understanding. If you really want not to search for another tutorial, this is for you. Production examples included.

When you come into the React world, you hear this frightening word: hook. Rest easy, if you’ve seen someone fishing, then React hooks are not much different. Let’s dive in!

What are React hooks?

Just as a fisherman catches fish with his hook, the React developer catches side effects with his hooks. In simple terms, we have a condition followed by an action. When the condition is met, the action is triggered.

Imagine yourself at a lake watching a fisherman catching fish. He attaches the hook onto his rod and then throws the rod into the lake. He then waits…One minute, two minutes, five minutes, one hour. Nothing is happening. Until his rod starts to shake: a fish was caught by his hook. What a satisfying moment! The hook he put onto his rod has caught something!

How to start using hooks and become better at it

I have to say that, from personal experience, the more time you spend writing code, the better you get at writing quality code. Think twice and code once. 

A common production task these days is displaying the cookie banner when a user lands on your website. We can implement this behavior with the useState and useEffect hooks. On page loading, with useEffect, we check local storage to see if the user has a choice regarding allowing cookies. If he does, we do not show the cookies banner. If not, we set it with the useState to display the banner. Here is the full implementation:

import { useEffect, useState } from 'react';

 
export const CookieBanner = () => {
  // We set the default value to false and set the correct value at page load
  const [showBanner, setShowBanner] = useState(false);
  
  // Here is the page load
  useEffect(() => {
    // Check local storage for user's cookie preference
    const cookieConsent = localStorage.getItem('cookieConsent');
    if (!cookieConsent) {
      // here we update the value, if necessary
      setShowBanner(true);
    }
  }, []); // 🟢 This sign [] means page load

  const handleAccept = () => {
    localStorage.setItem('cookieConsent', 'accepted');
    setShowBanner(false);
  };

  const handleDecline = () => {
    localStorage.setItem('cookieConsent', 'declined');
    setShowBanner(false);
  };

  if (!showBanner) {
    return null;
  }

  return (
    <div>
      <p>We use cookies to improve your experience. Do you accept cookies?</p>
      <button onClick={handleAccept}>Accept</button>
      <button onClick={handleDecline}>Decline</button>
    </div>
  );
};

export default CookieBanner;

 

If you read the above code, is it anything too fancy? I do not think so. However, I should draw your attention towards being able to identify when the page has loaded.

In React, page loading means that useEffect has an empty array condition. You can see it in the above example, I’m also writing it below. Notice the empty square brackets:

useEffect(() => {
    // Page loaded, code inside here will execute
    
    const cookieConsent = localStorage.getItem('cookieConsent');
    if (!cookieConsent) {
      setShowBanner(true);
    }
  }, []); // 🟢 NOTICE THE EMPTY SQUARE BRACKETS []

And if you haven’t seen a useState hook before, it is just a getter and a setter. Looking closely, we had this:

const [showBanner, setShowBanner] = useState(false);


The first variable (showBanner) is used just for retrieving the value and the second variable (setShowBanner) sets the value for the first variable. 

If I write this line:

setShowBanner(true);

And then I print its value 

console.log(showBanner) // 'true'

The value displayed will be 'true'.

I know that you want a more complicated explanation, but you just don’t need to know more than this. You know the saying: „Less is more”.

Usage in production apps

Now that you understand the fundamental theory behind hooks, it’s time to see how they are used in real world applications.

Infinite loading data

One of the most used scenarios for a React hook is the infinite loading data. You surely have seen something like this. Remember when trying to scroll to the bottom of an e-commerce page with no pagination? Remember those products which were coming and coming? Well, that’s what we’re talking about.

But how did they manage to implement it? Not too difficult. You need to know when the user has seen all the displayed products. If that is happening, then display the next products. And keep going forever, until the user becomes bored to scroll or until you display your entire catalog of products.

So, to speak in React terms, our hook catches every moment when the user has seen all the current products. Here is a very simplified code solution:

useEffect(() => {
  // Action: Get the next products
}, [Condition: User has seen all current products])

 

// Entire implementation is found towards the end of the article

Checking if a user is logged in

You need a React hook to check if a user is logged in or not (for web apps with authentication). Remember that loading spinner that sends you to the login page even if you wanted to see your profile page? Yeah, they also did that with a hook. Here’s how:

useEffect(() => {
  // Check if user is logged in or not
  // Action: Display Profile page or navigate to Login page
}, [Condition: User info has loaded])


if (is user info loading) {
  return <LoadingSpinner />;
}


return <ProfilePage />;

Newsletter popups

Another scenario for using a React hook that comes into mind is for displaying popups. Some blog sites display a „Subscribe to our newsletter” popup whilst the user is half-way into reading an article. I think you already imagine for yourself how the code looks like:

useEffect(() => {
  // Action: Display the popup
}, [Condition: User has read half of the article])

General rule scenario

In short, the rule is that whenever a side effect is needed, you will write a useEffect hook for it, combined with a useState hook.

Don’t say you master hooks until you also know how a useEffect gets triggered

There are three ways through which a useEffect code will begin to run.

1.On every page load

useEffect(() => {
  // Page loaded, code inside here will execute
}, []); // 🟢 NOTICE THE EMPTY SQUARE BRACKETS []

The cookie banner example covers this scenario.

2.On every component re-render

useEffect(() => {
  // Component re-rendered, code inside here will execute
}); // 🟢 NOTICE THE MISSING SQUARE BRACKETS []

In 2 years of writing React code, I’m still thinking if I used this type of hook more than once. There are various situations when a component re-renders. As far as you and I are concerned, we need to be sure that we really need this instead of the „1. On every page load” case. If you are a beginner and you don’t know the differences between a page load and a component re-render, or maybe you’re not sure about which one to use, follow this rule: if the code works with „1. On every page load hook”, then leave it like that. 

3.On every condition in the conditions array

useEffect(() => {
  // A condition was met, code inside here will execute
}, [condition1, condition2]);

This means that if condition1 OR condition2 OR both conditions modify or they are satisfied then the code inside your useEffect will run. This case is often used in practice. It is also used for the infinite loading data we talked about in the beginning. Let’s show a full implementation of it.

import { useState, useEffect, useRef, useCallback } from 'react';


interface Product {
  id: number;
  name: string;
  price: number;
}


export const InfiniteProductsPage = () => {
  const [products, setProducts] = useState<Product[]>([]);
  const [page, setPage] = useState(0);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('')
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef<IntersectionObserver | null>(null);
  const lastProductElementRef = useRef<HTMLDivElement | null>(null);


  const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => {
    const target = entries[0];
    if (target.isIntersecting && hasMore) {
      setPage((prevPage) => prevPage + 1);
    }
  }, [hasMore]);


  const fetchProducts = async (page: number) => {
    setLoading(true);


    try {
      const response = await fetch(`/api/products?page=${page}`);
      const newProducts: Product[] = await response.json();


      if (newProducts.length === 0) {
        setHasMore(false);
      } else {
        setProducts((prevProducts) => [...prevProducts, ...newProducts]);
      }
    } catch (err) {
      setError('Failed to load products');
    } finally {
      setLoading(false);
    }
  };


  useEffect(() => {
    if (hasMore) {
      fetchProducts(page);
    }
  }, [page, hasMore]);


  useEffect(() => {
    if (observer.current) observer.current.disconnect();
    observer.current = new IntersectionObserver(handleObserver, {
      root: null,
      rootMargin: '0px',
      threshold: 1.0,
    });
    const currentElement = lastProductElementRef.current;
    if (currentElement) observer.current.observe(currentElement);


    return () => {
      if (observer.current) observer.current.disconnect();
    };
  }, [handleObserver]);


  return (
    <div>
      <h1>Products</h1>
      <div>
        {products.map((product, index) => (
          <div ref={(products.length === index + 1) ? lastProductElementRef : null} key={product.id}>
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
      {loading && <div>Loading...</div>}
      {!!error && <div>Error...</div>}
      {(!hasMore && !loading) && <div>No more products to load.</div>}
    </div>
  );
};


export default InfiniteProductsPage;

The code above uses Intersection Observer API to detect whether the user has scrolled to the last element in the list. If he did, we increment the number of the page variable we are at fetching the products. And here's the trick:

useEffect(() => {
  if (hasMore) {
    fetchProducts(page);
  }
}, [page, hasMore]);

Notice that we have 2 variables based on which our useEffect gets triggered. If any of those variables modify their values, our code inside useEffect will run.

If the value of page variable or if the value of hasMore variable will change, then the code inside the useEffect will run. These lines:

if (hasMore) {
  fetchProducts(page);
}

So even if the page is incrementing, we do not make any API calls to fetch other products if we do not have more products than those which we already displayed on the page.

After months of React, I wrote my first custom hook

Do you know when I felt the need to write my first custom hook? I had two e-commerce pages with infinite loading data scenarios. One page was displaying a type of product, the other page was displaying another type of product. You can imagine how much code both pages had in common. And then I thought: What if I can take this logic of infinite loading data, together with the products fetching, and move it to a custom hook?  Here’s how you can do it:

import { useState, useEffect, useRef, useCallback } from 'react';

// Our custom hook to handle infinite scrolling fetching
export const useInfiniteScroll = <T,> ({productsApiUrl}: {productsApiUrl: string}) => {
  const [products, setProducts] = useState<T[]>([]);
  const [page, setPage] = useState(0);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [hasMore, setHasMore] = useState(true);
  const observer = useRef<IntersectionObserver | null>(null);
  const lastElementRef = useRef<HTMLDivElement | null>(null);

  const fetchProducts = useCallback(async (page: number) => {
    setLoading(true);

 

    try {
      const response = await fetch(`/api${productsApiUrl}?page=${page}`);
      const newProducts: T[] = await response.json();

      if (newProducts.length === 0) {
        setHasMore(false);
      } else {
        setProducts((prevProducts) => [...prevProducts, ...newProducts]);
      }
    } catch (err) {
      setError('Failed to load products');
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    fetchProducts(page);
  }, [fetchProducts, page]);

  const handleObserver = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const target = entries[0];
      if (target.isIntersecting && hasMore) {
        setPage((prevPage) => prevPage + 1);
      }
    },
    [hasMore]
  );

  useEffect(() => {
    if (observer.current) observer.current.disconnect();

    observer.current = new IntersectionObserver(handleObserver, {
      root: null,
      rootMargin: '0px',
      threshold: 1.0,
    });

    const currentElement = lastElementRef.current;
    if (currentElement) observer.current.observe(currentElement);

    return () => {
      if (observer.current) observer.current.disconnect();
    };
  }, [handleObserver]);

  return { products, loading, error, hasMore, lastElementRef };
};

export default useInfiniteScroll;

And the usage in the Products page:

import React from 'react';
import useInfiniteScroll from './useInfiniteScroll';

 

interface Product {
  id: number;
  name: string;
  price: number;
}


export const InfiniteProductsPage = () => {
  // 🟢 Our custom hook
  const { products, loading, error, hasMore, lastElementRef } = useInfiniteScroll<Product>({productsApiUrl: '/products'});

  return (
    <div>
      <h1>Products</h1>
      <div>
        {products.map((product, index) => (
          <div ref={index === products.length - 1 ? lastElementRef : null} key={product.id}>
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
      {loading && <div>Loading...</div>}
      {error && <div>Error: {error}</div>}
      {!hasMore && !loading && <div>No more products to load.</div>}
    </div>
  );
};

export default InfiniteProductsPage;

Notice how extracting our infinite loading data logic into a custom hook allows us to use the hook in multiple product pages with the least effort. Just pass in the API url and the interface of the product and we’re done. It’s that simple.

There is also a npm package usehooks-ts with many custom hooks that you will need in practice. I sometimes scroll down the page to look at the implementation of some. If you also do this, you will understand pretty fast how to write a good custom hook.

Don’t say you know enough yet

There has been a React article that really helped me to understand and write better useEffect hooks. That article is named „You Might Not Need an Effect”. Take your time to read this. It is very well written. If you really want to become better at React hooks, I cannot say how much help you will find out there.

There are also other types of hooks, like useRef, useMemo, useReducer and many others. Each hook is designed for a unique situation. You will discover where you need a specific hook when useState and useEffect will not help you. When you reach that point, it’s time to go to the official docs „Built-in React hooks”

Remember that you always learn by doing. Overcome your fear!

Do you want professional code in your app?

Let’s build it together!
Gabriel Arghire

Gabriel Arghire

Full Stack Developer