Required Reading
This article will cover the relationships you might run into
First and foremost, please go read the lovely series that TKDodo created on common pitfalls. This article specifically covers the props and useState relationship he covers in putting props to useState.
What I’ll be covering is what patterns or parent child component relationships tend to create this issue but first let’s highlight the main issue. This tends to happen when the child component is “smart”. This means that we’re trying to abstract away some work into this component.
useState’s initialState is determined and fixed on mount (first render)
When you use useState, you’re essentially creating a state variable
that is tied to the component. When you define the initialState
of the
state then this means that the value is locked in. This might seem like
an obvious thing and in some cases it is.
const [count, setCount] = useState(0);
In the case above this is obvious that the we start from 0 and move on there.
The initialState
starts at 0 and is fixed. So in what cases might we provide
an initialState
that would change?
Asynchronous Props
In one of the cases I ran into this issue is that the child component is a “controlled” component but the props passed in are determined asynchronously. Let’s say you have a list of tags for some todo application that you want to display through a multi-select. Sounds simple enough, we should then
- Fetch the tags
- Pass the tags as props
What could possibly go wrong here?
Well the issue was that the component I was using a component that used useState
to manage the label of the component. This was because in this component, if
the user selected 20 tags, we want to show maybe 2 of those names then have
+18 more.
So how does the React component look like? Well it was designed as an
uncontrolled component so it would have a selected
prop that would
be an array of strings. We also had an options
props that would represent
the list of labels and values that the select components typically have.
From that prop then we’d determine the selectedLabels
.
useEffect(() => {
setSelectedLabels(calculateResult(selected, options));
}, [selected]);
const [selectedLabels, setSelectedLabels] = useState(
calculateResult(selected, options), //determined initial state from the props
);
This an anti-pattern as TKDodo mentions in this part of the article. Even worse in my case this
- We’re missing the
options
prop inside the dependency array - The options was being determined by props that were being calculated asynchronously
Async Nature
The options
being passed in was initially empty since we haven’t fetched
the list of tags to present yet. Eventually in the parent component would load
the list of tags and from that create the list of options
that we would pass
into the child component.
const [options, setOptions] = useState([]);
useEffect(() => {
fetch("/api/tags")
.then((res) => res.json())
.then((data) => {
data.map((tag) => {
setOptions((prev) => [...prev, { label: tag.name, value: tag.id }]);
});
});
},
return (
...
<MultiSelect options={options} onChange={handleChange} />
)
The issue is that once the MultiSelect
component is mounted, it
already received the initialState
of []
and it went on to initialize
the label of the component to an empty array.
Once the tags come in, the props are updated but the MultiSelect component
is already mounted. The initialState
calculation won’t rerun.
What do we do then?
There are many ways to solve this problem but the simplest in my case
was to not use useState
to begin with. The state could be purely be computed
from the props so we never needed useState. That anti-pattern is usually
a code smell that should cause you to think “should I be using useState?”
So the MultiSelect
component was “smart”. It has state that it manages and
therefore uses useState
. The parent component got its data asynchronously
and then passed it into the child component but since the child component
used the asyncrounous props to determine the initialState
we ran
into problems.
TKDodo goes over multiple solutions in his putting props to use state article however I want to just conditional render solution.
In my case, if we really had to keep useState
then you have to consider
should we render this component to begin with?
Loading State (Probably?)
Can we add some loading state component that renders while we wait for the data? Once the data is there, we can now render the multi-select component for the first time.
const [options, setOptions] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch("/api/tags")
.then((res) => res.json())
.then((data) => {
data.map((tag) => {
setOptions((prev) => [...prev, { label: tag.name, value: tag.id }]);
});
});
},
return (
...
{isLoading && <Loading />}
(!isLoading && <MultiSelect options={options} onChange={handleChange} />)
)
Since the MultiSelect
component is now rendered conditionally, it ensures
that the initialState
is determined at the right point which
in our case is when the data is loaded.
Keys? (Probably Not?)
I don’t think there’s a great key we can use in this case for a list of items so I don’t think this would be a great solution.