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:
Hooks can only be called from function components or custom hooks (a wide topic for another post!)
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
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;
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;
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;
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;
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
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;
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;
👆 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;
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.
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;
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;
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
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>
);
}
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>
);
}
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>
);
}
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 whatref.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:
- Advanced Web Development with React - Mehul Mohan, pdf 📕
- Modern React with Redux - Stephen Grider, udemy 🏛
- React useRef Hook - Ceci García García, medium.com
- Storing Data in State vs. Class Variable - seanmcp.com
- A Thoughtful Way to Use React's useRef Hook - Aleem Isiaka, Smashing Magazine
Top comments (6)
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 aref
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:
The
useRef
function ensures thatmyRef
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:Here we are using
useRef
instead of passingf
as a dependency touseEffect
to prevent listeners to be added and removed on each render.I hope this provides a bit of extra understanding.
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.
Lo guarde para mostrarle a un compañero en lugar de explicarle yo, no me arrepiento ¡Muy bien explicado!
¡Muchas gracias! ¡Me alegro de que pueda ser útil! 😊
Fantastic post Ash!! You have come back strong 💪 I hope you being healthy after your minor car accident 👍
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! ⌨🎮