A Parasite Problem: Setting One Piece of State After Another with the useState Hook
Parasitic State Changes and Rendering
With React Hooks like useState
, you can work with different pieces of state in slices. This can be both wonderful OR a big headache requiring extra code, if you do not structure your state handling with useState
efficiently. (I previously wrote a blog on one way to handle a controlled form with the useState
hook that minimizes the duplication of functions for handling change in the form.)
Here I want to address what I would like to call “Parasitic State Changes.”
Repo Example To Work With
For this blog, I’ve made a repo that you can fork, clone, and use to follow along with the examples that follow. Let’s start with the Blog Code example here, beginning with the PostsComponent:
src/components/PostsComponent.jsimport React, { useState } from 'react'
import postsData from '../data/posts'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts) ...
}
Quick Note: for this simple repo, I’ve put some dummy data in src/data/posts
which is imported into the PostsComponent file.
What’s happening here?
- We set the initial value of all posts with the
useState
hook, setting the value to an array of posts objects imported and accessed viapostsData.posts:
const [posts, setPosts] = useState(postsData.posts)
- We set up a piece of state to filter posts that will be displayed based on whether they have enough likes:
const [filterNum, setFilterNum] = useState(null)
- Finally, because I see this sort of thing from time to time in students’ code, we’ve set up a different slice of state for what we want displayed after taking into account what our
filterNum
is. - The initial value is all posts, drawn from the state of
posts
that we set up with our firstuseState
hook. But, we will build out a rendering of posts using this slice of state to display ONLY those that meet the requirements of a filter, if there is afilterNum
that has been set:
const [postsToDisplay, setPostsToDisplay] = useState(posts)
Quick Recap:
posts
= all posts from the dummy data + any new posts we add to state
filterNum
= filter number that we can use to filter out which posts we want displayed
postsToDisplay
= only posts that meet the filter requirement, which we will then map out for display
Adding the Likes and New Posts Features
Updating Likes
Now, let’s add in more of the code of filtering and displaying the posts, piecemeal:
1.) Add a Post
Component to display in the PostsComponent
:
src/components/PostsComponent.jsimport React, { useState } from 'react'
import { CardGroup, Container } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts)const renderedPosts = postsToDisplay.map(post => {
return <Post key={post.id} postInfo={post}/>
})return(
<div>
<h1>Blog Posts</h1>
<Container>
<CardGroup>
{renderedPosts}
</CardGroup>
</Container>
</div>
}
If you want to look at the Post
component itself, you can go here. I’ll skip going through the display at the moment. Know that renderedPosts
will produce Post
components that display the title, text body, and number of likes (with an initial value of 0) for each blog post.
2.) Add functionality to update likes to Posts:
src/components/PostsComponent.jsimport React, { useState } from 'react'
import { CardGroup, Container } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts) const updateLikes = (id) => {
const postToUpdate = posts.find(post => post.id === id)
const updateIndex = posts.indexOf(postToUpdate)
postToUpdate.likes += 1
setPosts(prevPosts => [...prevPosts.slice(0, updateIndex),
postToUpdate, ...prevPosts.slice(updateIndex + 1)])
}const renderedPosts = postsToDisplay.map(post => {
return <Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>
})return(
<div>
<h1>Blog Posts</h1>
<Container>
<CardGroup>
{renderedPosts}
</CardGroup>
</Container>
</div>
}
Here, we build the updateLikes
arrow function and pass it down to the Post
component as a prop. In the Post
component (not shown here), the user can click the like button, and that will fire the updateLikes
function, passing in the liked post’s id.
In the PostsComponent
, the state for the post will be updated after finding the post, its index in the array of posts, and then spreading out the previous state of the posts array to change the like count of only the particular post whose id matches the one passed up from the Post
component.
Adding New Posts
- Add a controlled form in a new component called
PostForm
, and handle data submission in ourPostsComponent
. As before, I will skip building out the PostForm, but you can check it out here. (You can also review my other blog on building out controlled forms with React hooks here.)
src/components/PostsComponent.jsimport React, { useState } from 'react'
import { CardGroup, Container } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'
import PostForm from './PostForm'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts)const updateLikes = (id) => {
const postToUpdate = posts.find(post => post.id === id)
const updateIndex = posts.indexOf(postToUpdate)
postToUpdate.likes += 1
setPosts(prevPosts => [...prevPosts.slice(0, updateIndex),
postToUpdate, ...prevPosts.slice(updateIndex + 1)])
}const renderedPosts = postsToDisplay.map(post => {
return <Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>
})return(
<div>
<h1>Blog Posts</h1>
<Container>
<CardGroup>
{renderedPosts}
</CardGroup>
</Container>
<br></br>
<PostForm />
</div>
}
2. Add handleSubmit
to add a new post to the posts
in our state:
src/components/PostsComponent.jsimport React, { useState } from 'react'
import { CardGroup, Container } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'
import PostForm from './PostForm'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts)const updateLikes = (id) => {
const postToUpdate = posts.find(post => post.id === id)
const updateIndex = posts.indexOf(postToUpdate)
postToUpdate.likes += 1
setPosts(prevPosts => [...prevPosts.slice(0, updateIndex),
postToUpdate, ...prevPosts.slice(updateIndex + 1)])
}const renderedPosts = postsToDisplay.map(post => {
return <Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>
})const handleSubmit = (newPost) => {
setPosts([...posts, newPost])
}return(
<div>
<h1>Blog Posts</h1>
<Container>
<CardGroup>
{renderedPosts}
</CardGroup>
</Container>
<br></br>
<PostForm
postsCount={posts.length}
handleSubmit={handleSubmit}
/>
</div>
}
Here, we spread the previous state of posts
and add the new post to the end at form submission, passing down the function to the PostForm
component as a prop.
We also pass down the current number of posts as a prop called postsCount
, which will allow us to set the new post’s id as the next integer after the last post’s id, which is already set in our posts
slice of state.
Adding Filter Functionality
- Now we come to the filtering feature, where we first want to add buttons that the user can press to select filtered blog posts, and a
handleFilterNum
function that sets the state of thefilterNum
piece of state.
src/components/PostsComponent.jsimport React, { useState } from 'react'
import { CardGroup, Container, Button } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'
import PostForm from './PostForm'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts)const updateLikes = (id) => {
const postToUpdate = posts.find(post => post.id === id)
const updateIndex = posts.indexOf(postToUpdate)
postToUpdate.likes += 1
setPosts(prevPosts => [...prevPosts.slice(0, updateIndex),
postToUpdate, ...prevPosts.slice(updateIndex + 1)])
}const handleFilterNum = (num) => {
setFilterNum(num);
}const renderedPosts = postsToDisplay.map(post => {
return <Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>
})const handleSubmit = (newPost) => {
setPosts([...posts, newPost])
}return(
<div>
<h1>Blog Posts</h1>
<>
<Button onClick={() => handleFilterNum(0)}>All</Button>{' '}
<Button onClick={() => handleFilterNum(5)}>5+ Likes</Button>{' '}
<Button onClick={() => handleFilterNum(10)}>10+ Likes</Button>
</>
<Container>
<CardGroup>
{renderedPosts}
</CardGroup>
</Container>
<br></br>
<PostForm
postsCount={posts.length}
handleSubmit={handleSubmit}
/>
</div>
}
This is all pretty arbitrary, but I set up these buttons and filters along the following lines:
- all blogs (0 or more likes)
- 5+ likes
- 10+ likes
2. Filtering Posts with the filterNum
piece of state…we hit a snag!
const handleFilterNum = (num) => {
setFilterNum(num);
const displayablePosts = filterNum ? posts.filter(post =>
post.likes >= filterNum
) : posts
setPostsToDisplay(displayablePosts);
}
If we try to set the state for postsToDisplay
immediately after setting the state for filterNum
, we hit a problem. But why?
Filtering and the Problem of Asynchronous State Changes In Series: Parasitic State
The Asynchronous Issue
UsingsetFilteNum(num)
begins an asynchronous process, which likely will not be complete before we read the next line of code:
const displayablePosts = filterNum ? posts.filter(post =>
post.likes >= filterNum
) : posts
The ternary in use here asks if there is a filterNum
piece of state present. If the setFilterNum
process is not complete, and the filterNum
piece of state has not been set anew before we reach this line of code, then the ternary will be using an out-of-date piece of state (i.e. filterNum
) to determine whether or not to update the postsToDisplay
piece of state.
This is bad. The posts rendered will not represent the user’s choice of filter.
One piece of state, the postsToDisplay
, cannot successfully change until another piece of state, the filterNum
, has successfully changed. In other words:
One piece of state change depends on another piece of state change. One is “parasitic” on the other.
What do I mean by parasitic here? The second state change requires the first state change to complete. It’s like a shadow. When you move around with a backlight, the shadow can only move if, and only if, you move in relation to that backlight.
A Fix using the useEffect Hook
One way to address this asynchronous issue is to make sure that the ternary and setPostsToDisplay
functions fire after the filterNum
piece of state has successfully been set.
We can do this by allowing the PostsComponent
to finish rendering, which will mean that filterNum
has successfully been set, and then cause a rerender with the useEffect
hook:
src/components/PostsComponent.jsimport React, { useState, useEffect } from 'react'
import { CardGroup, Container, Button } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'
import PostForm from './PostForm'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)
const [postsToDisplay, setPostsToDisplay] = useState(posts)const updateLikes = (id) => {
const postToUpdate = posts.find(post => post.id === id)
const updateIndex = posts.indexOf(postToUpdate)
postToUpdate.likes += 1
setPosts(prevPosts => [...prevPosts.slice(0, updateIndex),
postToUpdate, ...prevPosts.slice(updateIndex + 1)])
}const handleFilterNum = (num) => {
setFilterNum(num);
}const renderedPosts = postsToDisplay.map(post => {
return <Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>
})useEffect(() => {
const displayablePosts = filterNum ? posts.filter(post =>
post.likes >= filterNum
) : posts
setPostsToDisplay(displayablePosts);
}, [posts, filterNum])const handleSubmit = (newPost) => {
setPosts([...posts, newPost])
}return(
<div>
<h1>Blog Posts</h1>
<>
<Button onClick={() => handleFilterNum(0)}>All</Button>{' '}
<Button onClick={() => handleFilterNum(5)}>5+ Likes</Button>{' '}
<Button onClick={() => handleFilterNum(10)}>10+ Likes</Button>
</>
<Container>
<CardGroup>
{renderedPosts}
</CardGroup>
</Container>
<br></br>
<PostForm
postsCount={posts.length}
handleSubmit={handleSubmit}
/>
</div>
}
The useEffect hook will run immediately after the PostsComponent
renders. We can be sure that the setFilterNum
asynchronous process is complete and filterNum
now represents the user’s selection.
- Only then do we change the state of
postsToDisplay
.
We also set the dependencies (the second argument in the useEffect callback) to watch for changes inposts
and filterNum
to make sure that the useEffect
hook will come into effect if either of those pieces of state is changed.
useEffect(() => {
const displayablePosts = filterNum ? posts.filter(post =>
post.likes >= filterNum
) : posts
setPostsToDisplay(displayablePosts);
}, [posts, filterNum])
This is important for two scenarios:
- a) suppose we add a new post. We will want the
postsToDisplay
piece of state to update with the new post, so that it will render all of our posts, including the new one, on the page. - b) we want the
filterNum
term to be used to filter which posts we display (the scenario we’ve been working on a for a bit here).
Quick Turn-Around
You may be asking at this point:
Aren’t we still rendering out-of-date data before the
useEffect
rerenders?
The answer is
Yes, but don’t worry about it: it’s a quick turn-around.
You likely won’t even see the initial render before the useEffect
state changes force a rerender.
A Better Way
Did we have to do all of that to get our data to display what we wanted when we add posts and filter them? In this case, “No.”
Here, it would have been better not to create a separate piece of state of postsToDisplay
at all.
We then can entirely avoid the “Parasitic State Problem.”
If you’re looking at the repo, here’s where you can switch the code by commenting out the “parasitic option” code and commenting in the “better option” code.
src/components/PostsComponent.jsimport React, { useState } from 'react'
import { CardGroup, Container, Button } from 'react-bootstrap'
import postsData from '../data/posts'
import Post from './Post'
import PostForm from './PostForm'const PostsComponent = () => {
const [posts, setPosts] = useState(postsData.posts)
const [filterNum, setFilterNum] = useState(null)const updateLikes = (id) => {
const postToUpdate = posts.find(post => post.id === id)
const updateIndex = posts.indexOf(postToUpdate)
postToUpdate.likes += 1
setPosts(prevPosts => [...prevPosts.slice(0, updateIndex),
postToUpdate, ...prevPosts.slice(updateIndex + 1)])
}const handleFilterNum = (num) => {
setFilterNum(num);
}const displayPosts = posts.map(post => (
<Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>))const displayFilteredPosts = posts.filter(post => (
post.likes >= filterNum
).map(post => (
<Post
key={post.id}
postInfo={post}
updateLikes={updateLikes}
/>)
))const handleSubmit = (newPost) => {
setPosts([...posts, newPost])
}return(
<div>
<h1>Blog Posts</h1>
<>
<Button onClick={() => handleFilterNum(0)}>All</Button>{' '}
<Button onClick={() => handleFilterNum(5)}>5+ Likes</Button>{' '}
<Button onClick={() => handleFilterNum(10)}>10+ Likes</Button>
</>
<Container>
<CardGroup>
{filterNum ? displayFilteredPosts : displayPosts}
</CardGroup>
</Container>
<br></br>
<PostForm
postsCount={posts.length}
handleSubmit={handleSubmit}
/>
</div>
}
Using the Ternary
Here, we just use a ternary instead of setting state for postsToDisplay
:
{filterNum ? displayFilteredPosts : displayPosts}
- If
filterNum
is present, then display filtered posts using the variable ofdisplayFilteredPosts
. The value of that variable is set to the output of mapping out posts that pass the filter using thefilterNum
. - If
filterNum
is not present (and still in an initial state ofnull
) then we simply display all the posts.
Avoiding Parasitic State Changes
This avoids the state change that depends on another state change. In fact, we’ve entirely removed the useState
hook for postsToDisplay
.
In doing so, we’ve eliminated the parasites.
That, undoubtedly, is better.