The rules of React hooks—and how we messed up
React Hooks have quickly become the recommended way to handle component local state and side effects in React function components. Getting started with hooks is quite straightforward, but you might need to change the way you think about your components, especially when it comes to the useEffect hook.
This blog assumes you know the basics of React Hooks – if you don’t you can find out more here – and will dive a bit deeper into how they should be used. I’ll also be sharing a bit about the mistakes we made and how it took us nearly a month to fix the mess.
React hooks - easy to learn, hard to master
React Hooks were launched in React version 16.8 and they have quickly become a popular way to handle components, local states, and component side effects, among other things. They’re quite easy to get started with, but they're challenging to master properly – you have to learn to think a bit differently compared to React’s traditional class components and lifecycle hooks, and there are certain rules that you have to follow.
Some examples of hooks and how to use them
The simplest hook is the useState hook, which takes as an argument the initial state. useState is a function that returns an array with two items in it: the first is the actual state and the second is a function that sets the state. Another of the built-in hooks is useEffect, which is for running side effects in your React function components. For example, if you have a shopping cart with a button to add a banana, when a banana is added you might want the document title to be updated as a side effect. With useEffects, you define the dependencies – you can think of it like defining the array and how often you want to run the function. If you leave it as an empty array, it will only run once, after the initial render; otherwise, it will run after every render of the function, unless you define the dependencies. So, when the state changes, React just calls this function again. And from a useEffect function, you can return a cleanup function.
To understand the useEffect cleanup, try this analogy from Ryan Florence. Imagine you have only one bowl in your house to eat cereal from. You wake up in the morning and eat cereal whether you're hungry or not – that's the initial render. Time passes, the state changes, and you become hungry again. Now you need to clean the bowl because it's dirty from when you ate earlier. You clean it up first and then you eat again – this is the same as React running a cleanup before running the effect again, which is also why when a component is unmounted it runs the cleanup when it's removed.
Easy mistakes to make with React hooks
I’ve just mentioned two of the most important hooks, but let's talk a bit about typical mistakes with hooks. The first mistake you might make when you start using useEffect is you might forget to add the dependency array, meaning your effect will run on every render. Why is this a problem? Imagine you're doing a fetch in your useEffect. This would happen on every render, causing a new render because something was changing the state of the component. This would make it render again, causing an infinite loop. Another typical mistake you can make when you start refactoring useEffects is having a useEffect that depends on the state that is saved inside it. This causes another infinite loop, but you can solve it by doing functional state updates instead of traditional useState calls.
Rules to follow – and what happens when you don’t
The simplest rule is that hooks must start with “use” – I think React will even warn you if you try to do something that doesn't start with use. Next, call hooks should only be used at the top level of your function components, so you can't nest them in statements. This is because React only relies on the order of the hook calls, so for every render you should call the same number of hooks so that React knows which hook is which. Finally, you can only call hooks from React functions. This should probably be self-explanatory, but when I started using hooks, I wanted to use them in some utility functions, and I realized quickly it just isn't possible. ESLint is very useful to check these rules. There are two plugins that I can recommend: react-hooks/rules-of-hooks and react-hooks/exhaustive-deps.
So where did we go wrong? In the beginning of a project, we used TSLint instead of ESLint, because at that point TSLint wasn't deprecated yet, so we thought it would be fine. We had the React Hooks plugin installed and enabled, but for some reason we forgot to enable the React Hooks rules, so TSLint wasn’t actually checking the rules. We had it there for months and didn't notice, and because we didn't know the rules well enough, we didn't notice that our code was piling up into a huge mess.
At that point we changed from TSLint to ESLint, which was already a big refactoring PR because we also made our rules stricter. At first we had the exhaustive deps rule disabled after the refactoring, as well as one huge component where we had to add the ESLint “disable React’s rules of hooks” line, because the file was just too large to be fixed in that PR. And then I started fixing this mess and enabled the exhaustive deps rule and decided to just do what ESLint tells us. I thought it would take me a couple of days, it ended up taking more than a month to fix just the exhaustive-deps violations, including causing some regressions in production.
Lessons learned with React
The most important thing we learned was to keep it simple, in both your React code base and in hooks. Even though you can make huge effects, it's better to split them into multiple effects – and if this makes your component code looks ugly, you can abstract it away into a custom hook. Secondly, you should always enable ESLint rules and enforce them, and it’s best to have ESLint in your editor. At this point I’d also like to recommend Betterer – a cool tool that can be used in legacy projects and in larger, ongoing projects to stop you from making the project worse over time. You add tests that make sure you stop doing the wrong things and forces you to do better in future. This is handy when you don’t have time, energy or resources for these kinds of huge refactoring PRs.
I also learned that custom hooks are quite cool. They are a really useful way to share code and logic between components. And during this refactoring I've learned when to use useReducer and when to use useState. useState is fine, but if you have more than, say, three useStates and you need to change a few of them at the same time but they're relying on each other, then it's better to use useReducer with one state object and then dispatch actions that update the state.
Where to learn more about React and React hooks
If you want to learn more about hooks and the rules of hooks, React’s official docs are amazing – they explain the rules and why you have to follow them. If I had read them to start with I wouldn’t have made the mistakes I did! I’d also recommend taking a look at Dan Abramov’s blog, overreacted.io. A complete guide to useEffect is interesting, as is React as a UI Runtime, and how are function components different from classes will teach you some important differences.
- Olavi HaapalaSoftware Developer