DEV Community

Cover image for A Quick Guide to Understanding React Hooks
Ash
Ash

Posted on • Updated on

A Quick Guide to Understanding React Hooks

This post will focus on React hooks - specifically useState, useEffect, and useRef. The examples are contrived for the sake of clarity, and don't observe all the typical best practices (like wrapping those emojis in span elements 😉).


React Hooks 🎣

React hooks allow us to use function components to accomplish things that were once only possible in Class components - creating, persisting, and sharing stateful and behavioral logic. Additionally, hooks let us take advantage of certain moments in the component lifecycle.

☝ Strictly speaking, some hooks offer a way to mimic lifecycle methods, and are not a 1:1 exchange.

🤔 What is a hook?

Beneath the terminology, and even React itself, a hook is a JavaScript function that follows a pre-defined schema in the form of syntax and expected arguments.

There are several hooks, each with their own intended purpose and pitfalls - but all hooks follow a couple of rules:

  1. Hooks can only be called from function components or custom hooks (a wide topic for another post!)

  2. For React to correctly manage state created with hooks, the order in which they are called must be identical with each re-render. Because of this all hooks must be called in the top level of the component.

☝ A function component is just that - a function! The top level is the first step, where we might declare a variable or do set up - before conditional tests, looping, or performing actions that could cause mutations, and prior to the return in the bottom level.

In this post we'll be covering the 3 hooks you're most likely to encounter in the wild: useState, useEffect, and useRef.


1️⃣ The useState Hook

In JavaScript, Class objects are built in such a way that the sharing of behaviors and values among many instances of themselves is accomplished quite easily, in part because of this - a confusing and deep topic of its own.

On the other hand, functions are scoped. Dumping and re-creating their local variables with each invocation. There is no prev or this, and persisting values isn't possible without an outside variable.

Function and Class components follow this same idea, which is why function components were commonly known as stateless components before the introduction of hooks. Without this, or that outside storage, these components were confined to displaying data they had no way to update... Enter the aptly named useState hook.

Predictably, useState taps into the React's state system - creating a place for function components to add independent slices of state, along with providing a way to update and share them.

Syntax & Use

useState syntax

To use any hook, we import it by name directly from React:

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

const App = () => {

    return (
        <div>
            <p>Give 🐒 some 🍌!</p>
            <button> + 🍌</button>
        </div>
    );
}; 

export default App; 
Enter fullscreen mode Exit fullscreen mode

To create a new state variable we'll call the useState function and pass the desired initial value, useState's only argument.

In Class components state is maintained as an object, and new state values are restricted to that format. The state variables created by useState are completely independent of one another, meaning our intial value could be an object - or a number, a string, an array, and so on.

We'll create a count with a number:

import React, { useState } from 'react'; 

const App = () => {
    // invoke 
    useState(0);    

    return (
        <div>
            <p>Give 🐒 some 🍌!</p>
            <button> + 🍌</button>
        </div>
    );
}; 

export default App;
Enter fullscreen mode Exit fullscreen mode

The useState function returns two things to us - the current state variable with assigned initial value, and a function to update that value. To get them we'll use array destructuring.

☝ We can name these values anything we want, but it's convention to use the varName/setVarName style:

import React, { useState } from 'react'; 

const App = () => {
    // destructure return
const [bananaCount, setBananaCount] = useState(0);  

    return (
        <div>
            <p>Give 🐒 some 🍌!</p>
            <button> + 🍌</button>
        </div>
    );
}; 

export default App;
Enter fullscreen mode Exit fullscreen mode

And just like that - we've created a piece of state that will be persisted between renders. If another slice of state was needed, we could easily create one. There's no hard limit on the amount of times useState can be invoked in a function component. This feature makes it easy to separate concerns and reduce naming conflicts.

Inside the component we can call and use them directly, no "this.state" required:

import React, { useState } from 'react'; 

const App = () => {
    const [bananaCount, setBananaCount] =   useState(0);
    const [appleCount, setAppleCount] = useState(0);

    return (
        <div>
            <p>Give 🐒 some 🍌!</p>
            <p>🍌 : {bananaCount} </p>
            <p>🍎 : {appleCount} </p>
            <button 
                onClick={() => setBananaCount(bananaCount + 1)}> + 🍌</button>
            <button 
                onClick={() => setAppleCount(appleCount + 1)}> + 🍎</button>
        </div>
    );
}; 

export default App;
Enter fullscreen mode Exit fullscreen mode

Beyond providing a way to create a new state variable, the useState hook also taps into the lifecycle of a component by triggering a re-render when the setter function is invoked and data is changed.


2️⃣ The useEffect Hook

There are a handful of key moments in a component's life that we care about, usually because we'd like to perform some action once they've occurred. These actions might include a network request, turning event listeners on or off, and so on.

In Class components we do that with the lifecycle methods componentWillMount, componentDidMount, and componentWillUnmount. In function components we can now encapsulate all of this behavior in the useEffect hook and accomplish something like lifecycle methods.

☝ I remember this hook as "use side effects," because it allows us to use certain moments and cause side effects when they occur.

Syntax & Use

useEffect syntax

To use, import from React:

// import 
import React, { useEffect, useState } from 'react'; 
// hardcoded data
const data = ["Doug", "Marshall", "Peter"];

const App = () => {
    const [coolDudes, setCoolDudes] = useState(data); 

    return (
        <div>Top 🆒 dudes: 
            {coolDudes.map((dude) => (
        <p>😎{dude}</p>
      ))}
        </div>
    );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Right now this component is rendering a list of coolDudes, but these are hardcoded values - what if the coolDudes ranking was maintained in real-time on a database? Using that, our component could always have the most recent data, and we wouldn't have to update it ourselves.

Before hooks we would need to convert this component to a Class or move the required logic higher up in the chain. With the useEffect hook we can accomplish this task inside a function component.

To use it, we need to provide two arguments. First a callback function - the "side effect" we want to invoke, and secondly a dependency array - telling that callback function when to run.

import React, { useEffect, useState } from 'react'; 
// axios fetching library added 
import axios from 'axios';

const App = () => {
    const [coolDudes, setCoolDudes] = useState(data); 
    // invoke hook
    useEffect(() => {
        axios.get('http://superCoolApi/coolDudes')
                .then((response) => {
                    setCoolDudes(response.data)
            });
    }, []); 

    return (
        <div>Top 🆒 dudes are: 
            {coolDudes.map((dude) => (
        <p>😎{dude}</p>
      ))}
        </div>
    );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

👆 You'll often see actions like making API calls in a useEffect hook. When reading a component, it can be mentally handy to think of useEffect as an "after render" action. The code inside should not be significant to component structure.

It's important to note that the first argument to useEffect may not be asynchronous. This ties back to the rule that all hooks must be called in identical order with each re-render in React. Though the callback function itself may not be asynchronous, we can perform async activity inside of it.

The example above used a Promise to resolve the API call, but JavaScript async and await can be used as well:

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

const App = () => {
    const [coolDudes, setCoolDudes] = useState(data); 
    // async fetch 
    useEffect(() => {
        const response = async () => {
            const { coolDudes } = await axios.get('http://superCoolApi/coolDudes')  
        }
        setCoolDudes(coolDudes.data);
            });
    }, []); 

    return (
        <div>Top 🆒 dudes are: 
            {coolDudes.map((dude) => (
        <p>😎{dude}</p>
      ))}
        </div>
    );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

The Dependency Array

In both of the examples above we passed an empty array as the second argument to the useEffect function. This second argument, known as the dependency array, is the key to telling React when the callback function should run.

By using an empty array, an array with one or more values (usually state or props), or omitting the argument completely, we can configure a useEffect hook to run automatically at particular times.

dependency array notes

The Cleanup Function

Broadly speaking, there are two types of actions performed in a useEffect function - those that require cleanup, and those that don't. So far we've only made a network request, an action that is invoked, returned, stored and forgotten about. It requires no cleanup.

But let's imagine a Search component with a useEffect hook that utilized the JavaScript setTimeout() method to wait for a user to stop typing before performing an action. This is a clever and somewhat common pattern to throttle API requests.

Let's take a look at a quick and contrived example:

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

const App = () => {
    // init state 
    const [search, setSearch] = useState("first search term");
    // search state shared with debouncedSearch state 👇
    const [debouncedSearch, setDebouncedSearch] = useState(search); 
    const [results, setResults] = useState([]); 

    useEffect(() => {
        const search = async () => {
            const { data } = await axios.get('http://searchApi.org', {
                // options object to attach URL params 
                // API call is completed with the DEBOUNCED SEARCH 
                // These change depending on the API schema 
                params: {
                    action: 'query', 
                    search: debouncedSearch
                },
        });
            setResults(data.query.search); 
        }; 
    if (debouncedSearch) search();
    }, [debouncedSearch]); 

    return (
        <React.Fragment>    
            <form>
                <label htmlFor="search">Search</label>
                <input 
                    type="search" 
                    value={search} 
                    onChange={(e) => setSearch(e.target.value}
                    placeholder="Search..." />
            </form> 
            <div>
                {results.map(result) => (
                    return <div key={result.id}>
                        <p>{result.title}</p>
            </div>
        </React.Fragment>
    );
};

export default App; 
Enter fullscreen mode Exit fullscreen mode

Right now this component renders a search bar and a list of search result titles. On first render the useEffect will be invoked, performing an API call with the initial value we passed to the search slice of state and then connected to the debouncedSearch state.

But if a user were to type a new search term nothing would happen. This is because the dependency array is watching the debouncedSearch state, and won't fire again until this state is updated. Meanwhile the input element is bound to the search state via its value prop.

We'll call another instance of the useEffect hook to connect these two separate states and set a timer while we're at it:

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

const App = () => {
    const [search, setSearch] = useState("first search term");
    const [debouncedSearch, setDebouncedSearch] = useState(search); 
    const [results, setResults] = useState([]); 

    useEffect(() => {
        const search = async () => {
            const { data } = await axios.get('http://searchApi.org', {
                params: {
                    action: 'query', 
                    search: debouncedSearch
                }
        });
            setResults(data.query.search); 
        }
    if (debouncedSearch) search(); 
    }, [debouncedSearch]); 

    useEffect(() => {
    // create a timer that must end before calling setDebouncedSearch
        const timerId = setTimeout(() => {
            setDebouncedSearch(search);
        }, 1000);   
    // useEffect can return a cleanup function! 🧼
    return () => {
        // this anonymous function will cleanup the timer in the case that the user keeps typing
        clearTimeout(timerId);
    };
    }, [search]);   

    return (
        <React.Fragment>    
            <form>
                <label htmlFor="search">Search</label>
                <input 
                    type="search" 
                    value={search} 
                    onChange={(e) => setSearch(e.target.value}
                    placeholder="Search..." />
            </form> 
            <div>
                {results.map(result) => (
                    return <div key={result.id}>
                        <p>{result.title}</p>
            </div>
        </React.Fragment>
    );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

The second useEffect hook is connected to the search input by its dependency array, watching for changes to the search state. When updated, the hook will be invoked and its callback function will then instantiate a timer with the JavaScript setTimeout() method.

If we did not cleanup behind this side effect, and the user kept typing, we would run into a problem. Multiple timers would be added to the stack, all waiting 1,000 milliseconds before triggering an API call. This would be a horrible user experience, that's easily avoided by returning the optional cleanup function.

This function will run right before the hook can be executed again, making it a safe place to cancel the last timer before a new one is created with the clearTimeout() method.

☝ The cleanup function doesn't have to be an anonymous arrow function, though that's generally how you'll see it.

3️⃣ The useRef Hook

The useRef hook is used to attach a reference directly to a DOM node, or to stash a piece of data that we expect to change but whose change we do not want to trigger a costly re-render. The useRef function returns a mutable ref object with a single property called current. This property will point to whatever we assign the ref to.

To get an understanding for how the useRef hook can perform interesting and useful tasks, let's jump right in to a use case.

Syntax & Use

useRef syntax

Because it was designed to do a pretty specific job, the useRef hook is seen less frequently than the previous two. But it can be used to facilitate the fluid UI interactions users have come to expect in modern apps.

For example, when we open a dropdown menu, or toggle the open status of some UI element we usually expect it to close again when: 🅰 We select one of the contained options, or click the element itself. 🅱 We click anywhere else in the document.

Prior to the days of React, when JQuery was more prevalent, this was done by adding an event listener. In React we still add event listeners - either with the onClick and onChange handlers that come out-of-the-box with React, or by using JavaScript's addEventListener() method in a side effect (i.e. a useEffect hook).

In the following, the example component is rendering a list of articles. When a title is clicked onArticleSelect is invoked and the activeIndex is reassigned, triggering the open status (created in the renderedArticles map statement) to change and the details of the article to expand.

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

// mock data
const data = [
  {
    id: 1,
    title: "...",
    details:
      "..."
  },
  {
    id: 2,
    title: "...",
    details: "..."
  }
];

export default function App() {
  const [articles] = useState(data);
  const [activeIndex, setActiveIndex] = useState(null);

    // change handler passed to the article element 
  const onArticleSelect = (id) => {
    if (id === activeIndex) setActiveIndex(null);
    else setActiveIndex(id);
  };

  // maps return from articles state
  const renderedArticles = articles.map((article) => {
        // isolate open status by performing a check
    const open = article.id === activeIndex;
    return (
      <article
        key={article.id}
        style={{ border: "1px solid gray" }}
        onClick={() => onArticleSelect(article.id)}
        className="article"
      >
        <h2>{article.title}</h2>
        <div> {open ? <p>{article.details}</p> : null} </div>
      </article>
    );
  });

  return (
    <div className="App">
      <div className="header">
        <h1>🔥Hot Off the Presses🔥</h1>
      </div>
      <section className="articles">{renderedArticles}</section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component has some of the functionality we want. The articles expand once clicked, but an article will only close again if: 🅰 It is clicked a second time or 🅱 Another article id is assigned to activeIndex state.

We want to add another layer to this by creating a way for the article to also close if the user clicks on any other element in the document. It's not too practical in this small example, but if this component was imported and rendered with many others this could be a quality-of-life improvement in the UI.

We'll use a useEffect hook to set up an event listener on the body element the first time the component is rendered. The listener will detect a click and reset the activeIndex to null when triggered:

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

const data = [
  {
    id: 1,
    title: "...",
    details:
      "..."
  },
  {
    id: 2,
    title: "...",
    details: "..."
  }
];

export default function App() {
  const [articles] = useState(data);
  const [activeIndex, setActiveIndex] = useState(null);

    // change handler passed to the article element 
  const onArticleSelect = (id) => {
    if (id === activeIndex) setActiveIndex(null);
    else setActiveIndex(id);
  };

  // turns on body event listener
  useEffect(() => {
    const onBodyClick = (e) => {
      // reset the active index
      setActiveIndex(null);
    };
    document.body.addEventListener("click", onBodyClick, { capture: true });
  }, []);

  const renderedArticles = articles.map((article) => {
    const open = article.id === activeIndex;
    return (
      <article
        key={article.id}
        style={{ border: "1px solid gray" }}
        onClick={() => onArticleSelect(article.id)}
        className="article"
      >
        <h2>{article.title}</h2>
        <div> {open ? <p>{article.details}</p> : null} </div>
      </article>
    );
  });

  return (
    <div className="App">
      <div className="header">
        <h1>🔥Hot Off the Presses🔥</h1>
      </div>
      <section className="articles">{renderedArticles}</section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

At first glance this seems like it will work - but there's a problem. When the title is clicked a second time it no longer toggles the display. This has to do with a programming principle known as event bubbling, and the way in which the React event system sits on top of that.

In short, the click events we assigned to the body and the article element go through a process of reconciliation. During that process events bubble up from the most parent element, and the events bound with addEventListener() will always be called before the event listeners we attach through React's onClick prop.

When the title is clicked a second time the event listener in the useEffect fires first, setting the activeIndex to null, before the onClick handler fires immediately after, setting the activeIndex back to the original index we were trying to dump.

To solve this we need a way to tell React when a user is clicking inside an article element and when they are clicking anywhere else. To do that, we'll employ the useRef function.

After importing the hook from React, we'll instantiate the ref as empty in the top level of the component.

☝ It's best practice to simply invoke the hook in the top level, saving the task of assigning it for the component return, or through a side effect, perhaps a useEffect hook. The rendering phase in a React function component should be 'pure' - meaning it should cause no side effects, and updating a ref is a side effect.

import React, { useState, useEffect, useRef } from "react";

const data = [
  {
    id: 1,
    title: "...",
    details:
      "..."
  },
  {
    id: 2,
    title: "...",
    details: "..."
  }
];

export default function App() {
  const [articles] = useState(data);
  const [activeIndex, setActiveIndex] = useState(null);
  const ref = useRef();

  const onArticleSelect = (id) => {
    if (id === activeIndex) setActiveIndex(null);
    else setActiveIndex(id);
  };

  useEffect(() => {
    const onBodyClick = (e) => {
      // adds a check: did the event occur in the ref node?
      if (ref.current.contains(e.target)) {
                // if yes, return early
        return;
      }
      setActiveIndex(null);
    };
    document.body.addEventListener("click", onBodyClick, { capture: true });

    // removes the event listener, should articles unmount 🧼
    return () => {
      document.body.removeEventListener("click", onBodyClick, {
        capture: true
      });
    };
  }, []);

  const renderedArticles = articles.map((article) => {
    const open = article.id === activeIndex;
    return (
      <article
        key={article.id}
        style={{ border: "1px solid gray" }}
        onClick={() => onArticleSelect(article.id)}
        className="article"
      >
        <h2>{article.title}</h2>
        <div> {open ? <p>{article.details}</p> : null} </div>
      </article>
    );
  });

  return (
    <div className="App">
      <div className="header">
        <h1>🔥Hot Off the Presses🔥</h1>
      </div>
      <section ref={ref} className="articles">
        {renderedArticles}
      </section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We attached the ref to the most parent element of the article elements, in this case that's the section with class name "articles".

The useEffect hook was also updated to perform a check - depending on the results of that check the body event listener will either return early, performing no function and allowing the onClick handlers to do their work unhindered, or it will execute and reset the activeIndex once more.

☝ The contains() method is available to all DOM elements - which is exactly what ref.current points to in this instance.


The introduction of hooks created a shift in the React ecosystem, allowing the once stateless function component to take on huge levels of complexity and functionality. While hooks don't offer a 1:1 trade-off from the lifecycle methods found in Class components, they allow us to create highly reusable, testable, and maintainable components and pieces of state.

The hooks covered here are only part of the story, and a complete list can be found in the Official React Docs.

Resources:

🦄 As always - thank you for reading! 🕶

Top comments (6)

Collapse
 
eecolor profile image
EECOLOR

Hello, thank you for writing the article!

From the article it seems your understanding of the useRef hook is a bit limited. Another way to describe a ref is to say: a piece of state that does not cause a re-render when changed. On top of that it is constructed in a way that ensures it is 'referentially stable'.

You could see it like this:

const myRef = { current: null }

function doSomething() {
  if (!myRef.current) myRef.current = 0
  myRef.current++
}
Enter fullscreen mode Exit fullscreen mode

The useRef function ensures that myRef is attached to the 'instance' of your component and will be removed once the component is unmounted.

useRef can be very useful in a variety of situations, here is an example:

function useOnClick(f) {
  const callbackRef = React.useRef(null)
  callbackRef.current = f

  React.useEffect(
    () => {
      document.addEventListener('click', handleClick)
      return document.removeEventListener('click', handleClick)

      function handleClick(e) {
        callbackRef.current(e)
      }
    },
    []
  )
}
Enter fullscreen mode Exit fullscreen mode

Here we are using useRef instead of passing f as a dependency to useEffect to prevent listeners to be added and removed on each render.

I hope this provides a bit of extra understanding.

Collapse
 
ash_bergs profile image
Ash • Edited

I really appreciate your comment!

I'm definitely still learning, and this was very helpful. I'll be amending some notes and testing this approach out in some code ASAP. Thank you for taking the time to help me understand this topic more deeply.

Collapse
 
enmel profile image
Enmanuel Marval

Lo guarde para mostrarle a un compañero en lugar de explicarle yo, no me arrepiento ¡Muy bien explicado!

Collapse
 
ash_bergs profile image
Ash

¡Muchas gracias! ¡Me alegro de que pueda ser útil! 😊

Collapse
 
sturpin profile image
Sergio Turpín

Fantastic post Ash!! You have come back strong 💪 I hope you being healthy after your minor car accident 👍

Collapse
 
ash_bergs profile image
Ash

I appreciate your kind words Sergio.
I'm happy to say I feel loads better, and it's great to be back at the blogging game! ⌨🎮