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.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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.
useEffectAs 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>

Compare the top AI development tools and models of November 2025. View updated rankings, feature breakdowns, and find the best fit for you.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the November 5th issue.

A senior developer discusses how developer elitism breeds contempt and over-reliance on AI, and how you can avoid it in your own workplace.

Examine AgentKit, Open AI’s new tool for building agents. Conduct a side-by-side comparison with n8n by building AI agents with each tool.
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 now
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.