In this article we discuss about a few things to avoid when writing React Hooks and forming good habits through understanding
Not using the ESLint plugin
Why not avoid the bugs related to hooks by following the rules suggested by installing the eslint-plugin-react-hooks? This can easily help you enforce the correct hooks execution order.
The default recommended configuration of these rules is to set "rules of hooks" to an error, and the "exhaustive deps" to a warning.
Changing hooks invocation order
The way React hooks internally work requires components to invoke hooks in the same order between renderings — always!
That’s exactly what suggests the first rule of hooks: Don’t call Hooks inside loops, conditions, or nested functions.
Solving the incorrect order of hooks means moving the return statement after invoking the hooks:
import React, { useEffect, useState } from 'react';
function Character({ id }) {
/** Move this! */
// if (!id) {
// return "Please select a game to fetch";
// }
const [character, setCharacter] = useState({
name: '',
description: '',
});
const fetchCharacter = async () => {
const response = await fetch(`https://swapi.dev/api/people/${id}`);
const data = await response.json();
console.log('here');
setCharacter(data);
};
useEffect(() => {
if (id) {
fetchCharacter();
}
}, [id]);
/** to here */
if (!id) {
return 'Please select a game to fetch';
}
return (
<main>
<h2>{character.name}</h2>
<div>Name: {character.birth_year}</div>
<div>Height: {character.height}</div>
</main>
);
}
export default Character;
Now, no matter id is empty or not, the useState() and useEffect() hooks are always invoked in the same order. So make sure that you don't change the order of the hooks invocation.
Thinking in Lifecycles
Before functional components becamae a thing, we had a nice and clear component API that made it easy for us to tell React when it should do certain things:
class MyComponent extends React.Component {
constructor() {
// initialize component instance
}
componentDidMount() {
// The component is first added to the page
}
componentDidUpdate(prevProps, prevState) {
// The component is updated on the page
}
componentWillUnmount() {
// The component is removed from the page
}
render() {
// render React elements
}
}
This declarative component made us think in lifecycles. But with React hooks, we should think about synchronizing the state of the side-effects with the state of the application.
import React, { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// Run once after DOMContentEvent loads
}, []);
useEffect(() => {
// Synchronize the state with the state of this component.
return function cleanup() {
// Cleanup the previous side-effect before running a new one
};
// Run the side-effect and it's cleanup to for the re-run
}, [a, b, c, d]); // When any of these change
useEffect(() => {
// Run every single time this component is re-rendered
});
return {
// Render React elements
};
}
Using stale state
For example, trying to increase the state variable count when a button is clicked:
import React, { useCallback } from 'react';
function Count() {
const [count, setCount] = useState(0);
const increase = useCallback(() => {
/** Avoid */
// setCount(count + 1);
/** Use a callback instead */
setCount(count => count + 1);
}, [count]);
const handleClick = () {
increase();
increase();
increase();
};
return (
<>
<button onClick={handleClick}>+</button>
<div>Counter: {count}</div>
</>
);
}
Even though increase() is called 3 times inside the handleClick(), each call will result in a stale state.
By using an updater function count => count + 1
, React gives you the latest actual state value.
If you use the current state to calculate the next state, always use a functional way to update the state: setValue(prevValue => prevValue + someResult)
Overthinking performance
Let's say we define an event handler for a child component:
function ParentComponent() {
function handleClick() {
console.log('clicked inside child component');
}
return <ChildComponent onClick={handleClick} />;
}
There are 2 reasons people worry about this:
- We're defining the function inside the component, meaning it's getting re-defined every single time
<ParentComponen/>
is rendered - We're passing handler function as a prop to
<ChildComponent />
which means it can't be optimized properly withReact.memo
and will suffer from "unnecessary re-renders"
But this is not quite true:
- Redefining too many functions - JavaScript engiens are fast enough for defining or redefining native functions.
- Just because a component re-renders, does not mean the DOM will get updated every time.
Creating Stale Closures
A closure is the JavaScript lexical scope that a function uses to wrap its varibles within. React hooks rely on this closure for them to function properly.
Let's look at the example below:
import React, { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
function handleClick() {
return setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
<button onClick={handleClick}>Show alert</button>
</div>
);
}
Whenever the setCount
is called the state gets a new reference. This means the original state doesn't have the new value, but a new state with the new value. When we click on the second button, the event handler captures the reference of the original state.
Even if we click the first button as many times as we like when the alert is displayed it will only show the old value.
To fix this stale-closure issue, will use a ref:
function App() {
const [count, setCount] = useState(0);
const latestValue = useRef(count);
const handleClick = () => {
setTimeout(() => {
alert(`count is: ${latestValue.current}`);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button
onClick={() => {
setCount((prev) => {
latestValue.current = prev + 1;
return prev + 1;
});
}}
>
Click me
</button>
<button onClick={handleClick}>Show alert</button>
</div>
);
}
Overthinking the testing of hooks
Writing test for hooks in a way that tests all of their components when they refactor to hooks is unnecessary.
test('Clicking on + increments the number', () => {
// using enzyme
const wrapper = mount(<Count />);
expect(wrapper.state('count')).toBe(0);
wrapper.instance().incrementCount();
expect(wrapper.state('count')).toBe(1);
});
Writing your tests to interact with what's being rendered, then it doesn't matter how that stuff gets rendered to the screen.
test('Clicking on + increments the number', () => {
// using React Testing Library
render(<Count />);
expect(screen.getByText('Total')).toBeInTheDocument();
userEvent.click(screen.getByText('Click'));
expect(screen.queryByText('1')).toBeInTheDocument();
});
Not cleaning up side effects
Side effects like API calls or browser API events like setTimeout or setInterval are asynchronous calls. If you don't clean up these, React will warn you every time on the console about a component is unmounted and you are trying to perform a state update on it.
useEffect(() => {
if (increase) {
const id = setInterval(() => {
setCount((count) => count + 1);
}, 1000);
return () => clearInterval(id);
}
}, [increase]);
Summary
- Make sure, you use the ESLint plugin for hooks
- Avoid changing hooks invocation order
- Stop thinking in Lifecycles
- Avoid using stale state
- Stop overthinking performance
- Avoid creating Stale Closures
- Don't overthink writing tests for testing hooks
- Clean up side effects