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.
Top comments (9)
That's really interesting and I think it holds up.
One common pattern for derived state is the selector pattern. Your selector is a function of state that returns your derived state value. This pattern is especially helpful for reuse.
If the app is large or complex enough, it becomes worth it to bring in a library like reselect to memoize selector return values.
reselect works well if you need to select things from „outside“ of react, like a redux store. If you are working with hooks based libraries, I prefer writing custom hooks and doing
useMemo
, which is pretty much the same.If you just store it in a
const
and remove any updates to const, how does it updatecategories
? Or is it because arrays are objects?categories
is created new in every render cycle bycomputeCategories
. After that, we don’t update it at all. Every timedata
changes, React will re-render and thus callcomputeCategories
again - giving us a new Array of categories.Oh I didn't know React rerenders the entire component for a state change. Thanks for the explanation!
Thank you so much for this series dude! great deal of information i learned from it
We've reduced complexity... But we've produced a lot of unnecessary re renders :) Or am I not right?)
I don’t see why - quite the opposite actually.
Before: initial render - fetch effect does setState, triggers render - effect that syncs data runs, triggers render. That’s 3 renders. In the final version, it’s just 2 renders: initial render + render from the one setState in the effect.
I didn’t mention this in the article because it shouldn’t be an argument though. The amount of renders usually doesn’t matter because renders are very fast. If they are not, try to make them fast rather than minimizing the amount of re-renders :)
What we do is calling
computeCategories
in every render, which we didn’t do before. This doesn’t matter unless this function is expensive. If we have proof that we need to optimize it, we can do:and now we call that function the same amount of times as before.