Don't over useState
Dominik D
Posted on December 29, 2020
useState
is considered to be the most basic of all the hooks provided by React. It is also the one you are most likely to use (no pun intended), alongside useEffect
.
Yet over the last couple of months, I have seen this hook being misused a lot. This has mostly nothing to do with the hook itself, but because state management is never easy.
This is the first part of a series I'm calling useState pitfalls, where I will try to outline common scenarios with the useState hook that might better be solved differently.
What is state?
I think it all boils down to understanding what state is. Or more precisely, what state isn't. To comprehend this, we have to look no further than the official react docs:
Ask three questions about each piece of data:
Is it passed in from a parent via props? If so, it probably isn’t state.
Does it remain unchanged over time? If so, it probably isn’t state.
Can you compute it based on any other state or props in your component? If so, it isn’t state.
So far, so easy. Putting props to state (1) is a whole other topic I will probably write about another time, and if you are not using the setter at all (2), then it is hopefully pretty obvious that we are not dealing with state.
That leaves the third question: derived state. It might seem quite apparent that a value that can be computed from a state value is not it's own state. However, when I reviewed some code challenges for a client of mine lately, this is exactly the pattern I have seen a lot, even from senior candidates.
An example
The exercise is pretty simple and goes something like this: Fetch some data from a remote endpoint (a list of items with categories) and let the user filter by the category.
The way the state was managed looked something like this most of the time:
import { fetchData } from './api'
import { computeCategories } from './utils'
const App = () => {
const [data, setData] = React.useState(null)
const [categories, setCategories] = React.useState([])
React.useEffect(() => {
async function fetch() {
const response = await fetchData()
setData(response.data)
}
fetch()
}, [])
React.useEffect(() => {
if (data) {
setCategories(computeCategories(data))
}
}, [data])
return <>...</>
}
At first glance, this looks okay. You might be thinking: We have an effect that fetches the data for us, and another effect that keeps the categories in sync with the data. This is exactly what the useEffect hook is for (keeping things in sync), so what is bad about this approach?
Getting out of sync
This will actually work fine, and it's also not totally unreadable or hard to reason about. The problem is that we have a "publicly" available function setCategories
that future developers might use.
If we intended our categories to be solely dependent on our data (like we expressed with our useEffect), this is bad news:
import { fetchData } from './api'
import { computeCategories, getMoreCategories } from './utils'
const App = () => {
const [data, setData] = React.useState(null)
const [categories, setCategories] = React.useState([])
React.useEffect(() => {
async function fetch() {
const response = await fetchData()
setData(response.data)
}
fetch()
}, [])
React.useEffect(() => {
if (data) {
setCategories(computeCategories(data))
}
}, [data])
return (
<>
...
<Button onClick={() => setCategories(getMoreCategories())}>Get more</Button>
</>
)
}
Now what? We have no predictable way of telling what "categories" are.
- The page loads, categories are X
- User clicks the button, categories are Y
- If the data fetching re-executes, say, because we are using react-query, which has features like automatic re-fetching when you focus your tab or when you re-connect to your network (it's awesome, you should give it a try), the categories will be X again.
Inadvertently, we have now introduced a hard to track bug that will only occur every now and then.
No-useless-state
Maybe this is not so much about useState after all, but more about a misconception with useEffect: It should be used to sync your state with something outside of React. Utilizing useEffect to sync two react states is rarely right.
So I'd like to postulate the following:
Whenever a state setter function is only used synchronously in an effect, get rid of the state!
— TkDodo
This is loosely based on what @sophiebits posted recently on twitter:
This is solid advice, and I'd go even further and suggest that unless we have proven that the calculation is expensive, I wouldn't even bother to memoize it. Don't prematurely optimize, always measure first. We want to have proof that something is slow before acting on it. For more on this topic, I highly recommend this article by @ryanflorence.
In my world, the example would look just like this:
import { fetchData } from './api'
import { computeCategories } from './utils'
const App = () => {
const [data, setData] = React.useState(null)
- const [categories, setCategories] = React.useState([])
+ const categories = data ? computeCategories(data) : []
React.useEffect(() => {
async function fetch() {
const response = await fetchData()
setData(response.data)
}
fetch()
}, [])
-
- React.useEffect(() => {
- if (data) {
- setCategories(computeCategories(data))
- }
- }, [data])
return <>...</>
}
We've reduced complexity by halving the amount of effects and we can now clearly see that categories is derived from data. If the next person wants to calculate categories differently, they have to do it from within the computeCategories
function. With that, we will always have a clear picture of what categories are and where they come from.
A single source of truth.
Posted on December 29, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.