Little React Things is a series of short, React-focused posts where I share some things I've learned over the past few years of developing with React. I hope you find them helpful. These posts might be most helpful for those who have at least a little experience with React already.
Back when React Hooks first came out, the useEffect
was introduced and many like myself were like "great, a utility to react to things changing and keeping things in sync". And that is partially true, but it's not as simple as that. The new React docs that are under construction are more explicit about how and how not to use useEffect
in the You Might Not Need an Effect.
Even with the guidance from the new docs, these useEffect
s still tend to pop up in the wild. They can be more than just unnecessary, and actually problems that should be fixed.
A hasty reaction
Let's say we have an app where a user can buy clothes, but instead of picking out each individual article of clothing, they can pick a bunch of articles of clothing that they like and set a price. Then the application shows the user a bunch of outfits that fit the chosen parameters.
The parent component of the app is where the user can pick the different pieces of clothing from the main list. Then there is a child component, OutfitList
that lists all the outfits that fit the parameters. This list of outfits is generated by a very cool helper function called createOutfits
; it's not an outrageously expensive function, but we definitely don't want to call it each time the OutfitList
is rendered (this kind of function would not be done in the browser typically, but this is a React post, so here we are).
Let's take a look at a way we could create this OutfitList
component.
function OutfitList({ clothingItems, maxPrice }) {
const [outfits, setOutfits] = useState(null)
useEffect(() => {
const createdOutfits = createOutfits(clothingItems, maxPrice)
setOutfits(createdOutfits)
}, [clothingItems, maxPrice])
return (
<div>
{outfits ? outfits.map(outfit => (
<Outfit key={outfit.id} outfit={outfit} />
)) : null}
</div>
)
}
It's great right? We have a useEffect
set up to react to changes in the clothing item list or price limit and recreates the list of outfits and updates the outfits
state. We only call the createOutfits
function when we need to and we keep outfits
in sync. All good? Well, there are a couple of issues with this solution.
Reactions are usually not a good idea
Hold up, but it's called "React", what do you mean reactions aren't a good idea? Well, the reacting that we do want is reacting to user actions primarily and also asynchronous events. Code that synchronously reacts to your own code, on the other hand, is not desirable.
When the OutfitList
first renders, even if clothingItems
is already populated outfits
will still be null
. This is because useEffect
runs after the render. So, for the first render we have populated clothingItems
and null
outfits
. The state is out of sync for that first render which is no good. Our components then need to be made in such a way to account for these discrepancies. For example, we need to add a check to see if outfits
is null
as shown in the code above.
Furthermore, in the last post, I gave the guidance to not call the function that is a React application more than you need to, and here the useEffect
is calling that function immediately after it is first called. So instead of one render, we will always have at least two renders each time either outfits
or maxPrice
changes.
State should be the source of truth
For our OutfitList
component, let's assume that clothingItems
and maxPrice
are state in the parent component. Given that, we have three states: clothingItems
, maxPrice
and outfits
. But are those three states actually representing different things? I would argue no. outfits
is just a derivation of clothingItems
and maxPrice
.
This isn't good, state should be a source of truth. But here we have clothingItems
, maxPrice
, and outfits
as state, so which is the source of truth? From the user's perspective, I would probably say it's clothingItems
and maxPrice
.
Why is this important? Well, what if down the road a developer is going to add a feature where the user can add a new outfit to the outfit list. The correct way to do this would be suggest some outfits that include a piece that weren't in the clothingItems
list or that was a slightly higher price than maxPrice
and then if the user picks one of those outfits, adjust those states accordingly. But this developer could see some state called outfits
and simply just append a new outfit to that list. Uh oh, now we're really out of sync. We have a list of outfits that could not be created from our set of clothingItems
or that is a higher price than the price limit. This example is a little contrived, but I hope you get the point at least.
Deriving forward
function OutfitList({ clothingItems, maxPrice }) {
const outfits = useMemo(() => {
return createOutfits(clothingItems, maxPrice)
}, [clothingItems, maxPrice])
return (
<div>
{outfits.map(outfit => (
<Outfit key={outfit.id} outfit={outfit} />
)}
</div>
)
}
Here we are being explicit that outfits
are derived from clothingItems
and maxPrice
. On the first render, outfits
will have a value (given that clothingItems
and maxPrice
have values of course), we aren't immediately re-rendering after the first render, and we are still only calling createOutfits
when clothingItems
or maxPrice
changes thanks to useMemo
.
So there you have it, with one technique we followed all three guidelines from the last post, we reduced the number of function calls (renders), simplified the parameters (state), and used memoization. Until next time!
Top comments (0)