Even though React Hooks components have been around for a few years now, there are still some mistakes that happen quite often when using these features. In this article, we will discuss some common missteps when making the transition to React Hooks and how to avoid them.
While React Hooks components allow us to achieve the same functionality as its predecessor, the process by which this happens is significantly different. With class
components, side effects are run during the various component lifecycles. By comparison, React Hooks runs side effects as a result of changes to the component’s state. This can sometimes lead to duplication.
Take, for example, a Counter
component that updates the page title any time the state count
is updated:
class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } componentDidMount() { document.title = `You clicked ${this.state.count} times`; } componentDidUpdate() { document.title = `You clicked ${this.state.count} times`; } render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } }
You’ll notice componentDidMount
and componentDidUpdate
have the same code. This is because the code has to be run both when the component is added to the DOM, and on all subsequent state updates. This duplication happens because we have to run the same side effect at different stages of the component lifecycle. Even if the code is extracted into a method, the method still has to be called twice, which does not help to avoid duplication.
Since React Hooks run side effects as a result of state changes, you can avoid the problem of duplication like so:
import React, { useState, useEffect } from 'react'; function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); }
Since useEffect
by default runs after every render and on subsequent updates, it solves the problem of duplication.
You can also use useState
to react (no pun intended) to state changes of specific state variables:
function Counter() { const [count, setCount] = useState(0) useEffect(() => { document.title = `You clicked ${count} times`; }, [count]); ... }
In this case, the “effect” is run any time the state count
changes. This keeps the logic of the state and the side effect close together which is easier to read and understand.
useEffect
As useful as the useEffect
Hook can be, there are times where it can become “too useful” and thus overcomplicate code. This can be seen in the case of the TodoList
component below:
function TodoList({ onSuccess }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [todos, setTodos] = useState(null); const fetchTodos = () => { setLoading(true); callApi() .then((res) => setTodos(res)) .catch((err) => setError(err)) .finally(() => setLoading(false)); }; useEffect(() => { fetchTodos(); }, []); useEffect(() => { if (!loading && !error && todos) { onSuccess(); } }, [loading, error, todos, onSuccess]); return <div>{todos.map(todo => <Todo item={todo} /> )}</div>; }
There are two useEffect
Hooks: one is run on initial render, and the second is run when loading
and error
are false but todos
have been populated (in other words, when the API call was successful and calls the method onSuccess
that was passed as a prop
). This is a case of over-engineering that actually ends up complicating the code.
Since we only want to run onSuccess
when the API call is successful, it can be invoked in the .then
handler and the component like so:
function TodoList({ onSuccess }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [todos, setTodos] = useState(null); const fetchTodos = () => { setLoading(true); callApi() .then((res) => { setTodos(res); onSuccess(); }) .catch((err) => setError(err)) .finally(() => setLoading(false)); }; useEffect(() => { fetchTodos(); }, []); return <div>{todos.map(todo => <Todo item={todo} /> )}</div>; }
To limit complications to your code, avoid handling actions with useEffect
.
useState
when no re-render is requiredOne of the key elements of any React component is its state as it drives data flow, and the resulting update to the user interface. Every state change triggers a re-render of the component as well as its children components. To avoid performance issues, only use useState
when absolutely necessary.
To understand this, let’s look at a simple component that has two buttons — one that updates the state count
and another that uses the state count
to make an API call:
function ClickButton(props) { const [count, setCount] = useState(0); const onClickCount = () => { setCount((c) => c + 1); }; const onClickRequest = () => { apiCall(count); }; return ( <div> <button onClick={onClickCount}>Counter</button> <button onClick={onClickRequest}>Submit</button> </div> ); }
In this example, you’ll see that the state is never used in the render
method, which means it will cause an unnecessary render every time the state changes. In this case, using the useRef
Hook will be more appropriate because it retains its value between renders and also does not cause a re-render.
The component should then be written like so:
function ClickButton(props) { const count = useRef(0); const onClickCount = () => { count.current++; }; const onClickRequest = () => { apiCall(count.current); }; return ( <div> <button onClick={onClickCount}>Counter</button> <button onClick={onClickRequest}>Submit</button> </div> ); }
To avoid unnecessary re-rendering, avoid using useState
when the state does not update the user interface.
onClick
to trigger navigationThis issue is a general bad practice in web development and not specific to React Hooks. Let’s say we have a button that links to another page:
function ClickButton(props) { const history = useHistory(); const onClick = () => { history.push('/next-page'); }; return <button onClick={onClick}>Go to next page</button>; }
It may be tempting to stick an onClick
listener on the button and use useHistory
to navigate to the subsequent page since it is simple client-side navigation … right? Wrong.
The first problem in this scenario is that this button is not detected as a link, which makes it nearly impossible to detect for screen readers. Moreover, hovering over the button does not show the subsequent link at the bottom corner of the screen, even though this is a UX hint that many users have come to associate with links.
To avoid creating confusion for users, <Link />
should always be used to trigger navigation like so:
function ClickButton(props) { return ( <Link to="/next-page"> <span>Go to next page</span> </Link> ); }
Rewriting tests becomes a concern when a class component is converted to a function component with hooks. Understanding whether a test needs to be rewritten depends on if the test depends on an implementation specific detail of the component. A sample test is below:
test('updateCount updates the count state', () => { // using enzyme const wrapper = mount(<Counter initialCount="0" />) expect(wrapper.state('count')).toBe(0) wrapper.instance().updateCount(1) expect(wrapper.state('count')).toBe(1) })
If the component is written with hooks, then the test will break because it depends on properties that are specific to class components (i.e., .state
).
A better approach would be to test from a user’s point of view because the user is not concerned with the implementation details of the component. In this case, rewriting the test will look like this:
import { render, screen } from '@testing-library/react'; test('updateCount updates the count state', () => { // using React Testing Library render(<Counter initialCount="0" />) expect(screen.getByText("Initial count: 0")).toBeInTheDocument() userEvent.click(screen.getByText("Update count")) expect(screen.getByText("Initial count: 1")).toBeInTheDocument() })
With this method, the test does not break as a result of changes in implementation details.
In this article, we’ve looked at some common mistakes that occur when transitioning to React Hooks. We’ve also seen how to avoid those mistakes and best practices to follow. Learn more about React Hooks here.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
One Reply to "Avoiding common mistakes in React Hooks"
In the section about “Using onClick to trigger navigation,” it’s not very clear what is. I know that is from React Router, and that Gatsby and Next also provide similar components, but it’s probably worth mentioning that in that scenario would be a custom React component that uses an under the hood.