DEV Community

Cover image for Don't over useState
Dominik D
Dominik D

Posted on • Originally published at tkdodo.eu

Don't over useState

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 <>...</>
}
Enter fullscreen mode Exit fullscreen mode

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>
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

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 <>...</>
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
nas5w profile image
Nick Scialli (he/him)

Whenever a state setter function is only used synchronously in an effect, get rid of the state!

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.

// Defined in a shared file/location
const getFullName = state => state.firstName + " " + state.lastName;

// Used in a component
const fullName = getFullName(state);
Enter fullscreen mode Exit fullscreen mode

If the app is large or complex enough, it becomes worth it to bring in a library like reselect to memoize selector return values.

Collapse
 
tkdodo profile image
Dominik D

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.

Collapse
 
varuns924 profile image
Varun S

If you just store it in a const and remove any updates to const, how does it update categories? Or is it because arrays are objects?

Collapse
 
tkdodo profile image
Dominik D

categories is created new in every render cycle by computeCategories. After that, we don’t update it at all. Every time data changes, React will re-render and thus call computeCategories again - giving us a new Array of categories.

Collapse
 
varuns924 profile image
Varun S

Oh I didn't know React rerenders the entire component for a state change. Thanks for the explanation!

Collapse
 
ragrag profile image
Raggi

Thank you so much for this series dude! great deal of information i learned from it

Collapse
 
fruity4pie profile image
Aslan

We've reduced complexity... But we've produced a lot of unnecessary re renders :) Or am I not right?)

Collapse
 
tkdodo profile image
Dominik D

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 :)

Collapse
 
tkdodo profile image
Dominik D

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:

const categories = React.useMemo(
  () => data ? computeCategories(data) : [],
  [data]
)
Enter fullscreen mode Exit fullscreen mode

and now we call that function the same amount of times as before.