Improper use of React Hooks can cause problems with performance and bugs. Learn from some common mistakes developers make with React Hooks and solutions for how best to avoid them.
React Hooks have revolutionized the React ecosystem since their inception in React 16.8. They offer the ability to leverage state and lifecycle features inside functional components, which was previously only possible in class components.
Earlier on, we’ve written some deep dives into some of the core React Hooks like the useEffect, useContext and useReducer hooks.
Despite the utility and usefulness React Hooks provide us, improper use can lead to performance degradation and elusive bugs. In this article, we’ll outline some common mistakes developers make when using React Hooks and solutions on how best to avoid them.
It’s a common error to use hooks inside loops, conditional statements or nested functions. This can lead to inconsistent hook calls between renders, resulting in erratic behaviors and hard-to-find bugs.
function Component() {
if (condition) {
const [state, setState] = useState(initialState);
}
// ...
}
Hooks must always be invoked in the same order during every component render. This helps promotes more predictable and stable component behavior.
function Component() {
const [state, setState] = useState(initialState);
if (condition) {
// manipulate or use the state here.
}
// ...
}
Over-reliance on state is another frequent pitfall. Not every variable in a component needs to be part of component state, especially if it doesn’t trigger a re-render. By distinguishing between stateful and non-stateful data, we can optimize our component’s performance by reducing unnecessary re-renders.
// unneccessary if we don't need a re-render when value changes
const [value, setValue] = useState("");
// instead, we can just assign a variable
let value = "";
React state is inherently immutable, and direct mutation is an error sometimes made by developers.
const [state, setState] = useState([]);
state.push("new item"); // Incorrect!
Direct state mutation doesn’t immediately trigger a re-render, causing a discrepancy between the displayed state and the actual state of the component.
Instead, we should always use the state update function available to use from the useState
hook to update state. When state changes this way, it triggers a necessary re-render which ensures consistency between the component’s actual state and its rendered output.
const [state, setState] = useState([]);
setState((prevState) => [...prevState, "new item"]); // Correct!
A common pitfall when using React Hooks is the misuse of stale state data. This can occur when we directly reference the state variable in consecutive state updates. Because state updates may be asynchronous, the state variable might not reflect the latest value when it’s referenced in successive calls.
const [count, setCount] = useState(0);
setCount(count + 1);
setCount(count + 1); // Incorrect! `count` could be stale.
In the incorrect usage above, count
is referenced directly within each setCount()
call. If these state updates are batched (as they often are in event handlers and lifecycle methods), then both calls to setCount()
will use the same initial value of count
, leading to an incorrect final state.
Instead, we can use the updater function form of setCount()
, which ensures that each update is based on the latest state. The updater function takes the previous state as an argument and returns the new state, so each consecutive update will have the correct value, preventing stale state data issues.
const [count, setCount] = useState(0);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1); // Correct! Using the updater function.
The useEffect
hook is often misused to run on every component update. Although this is necessary in some scenarios, often we only need to run an effect once on mount, or when specific dependencies change.
useEffect(() => {
fetch("https://example.com/api").then(/* ... */);
}); // Will run every render
In the example above, running the effect on every render will result in numerous API calls which could lead to performance degradation and inconsistent state.
By supplying an empty dependency array, we can ensure the effect runs only once on mount, similar to the traditional componentDidMount
lifecycle method in class components.
useEffect(() => {
fetch("https://example.com/api").then(/* ... */);
}, []); // Runs once on mount
Side effects are operations that interact with the outside of a function scope, affecting the external environment. This could involve data fetching, subscriptions, manual DOM manipulations, and timers like setTimeout
and setInterval
.
Failing to clear side effects in the useEffect
hook is another common error that can occur that can lead to unexpected behavior and/or memory leaks.
useEffect(() => {
const timer = setTimeout(() => {
// do something
}, 1000);
// Missing cleanup!
});
By returning a cleanup function from our effect, we can remove side effects before the component unmounts or before the effect runs again, preventing memory leaks.
useEffect(() => {
const timer = setTimeout(() => {
// do something
}, 1000);
// cleanup function to remove the side effect
return () => {
clearTimeout(timer);
};
});
A common mistake is neglecting to include dependencies in the dependency array of useEffect
, useCallback
or useMemo
hooks. If a variable from the component scope is used within these hooks’ callbacks, it should often be included within the dependency array.
function Component({ prop }) {
useEffect(() => {
console.log(prop);
}, []); // Missing dependency: 'prop'
// ...
}
Omitting dependencies can lead to the capture of stale variables that may have changed since the effect was last run, which can lead to unpredictable behavior.
function Component({ prop }) {
useEffect(() => {
console.log(prop);
}, [prop]); // Correct dependency array
// ...
}
By correctly declaring all dependencies, we ensure that the hook updates whenever a dependency changes. This results in consistent and expected behavior.
Memoization is an optimization technique that primarily speeds up applications by storing the results of expensive function calls and reusing the cached result when the same inputs occur again. This technique is incredibly useful for functions that are computationally intensive and are frequently called with the same arguments.
React provides two hooks, useMemo
and useCallback
, that implement memoization. useMemo
is used for memoizing the return value of a function, while useCallback
is used for memoizing the instance of the function itself.
If expensive functions are not correctly memoized, they can provoke unnecessary re-renders. In the following example, if the parent component re-renders for any reason, expensiveFunction()
will be recreated. This will cause unnecessary re-renders in the child component, as it will receive a new prop each time, regardless of whether the actual computation result has changed.
function Component({ prop }) {
const expensiveFunction = () => {
// Expensive computation
};
return <ChildComponent func={expensiveFunction} />;
}
We can optimize this by using the useCallback
hook:
function Component({ prop }) {
const expensiveFunction = useCallback(
() => {
// Expensive computation
},
[
/* dependencies */
]
);
return <OtherComponent func={expensiveFunction} />;
}
In this correct usage, the useCallback
hook ensures that expensiveFunction()
is only recreated when its dependencies change. This means that the child component will only re-render when the computed value changes, preventing unnecessary re-renders and enhancing the performance of the React application.
React Hooks have significantly transformed the React landscape by allowing the use of state and lifecycle features in functional components. However, they can also present unique challenges.
Errors such as using hooks within conditional statements, overusing state, mutating state directly, misusing stale state data, etc. can lead to inefficient code and difficult-to-trace bugs. By avoiding these common mistakes and following best practices, developers can fully harness the potential of React Hooks, leading to more performant, stable and maintainable React applications.
Hassan is a senior frontend engineer and has helped build large production applications at-scale at organizations like Doordash, Instacart and Shopify. Hassan is also a published author and course instructor where he’s helped thousands of students learn in-depth frontend engineering skills like React, Vue, TypeScript, and GraphQL.