Skip to main content

Updating State With useState Hook

· 5 min read

Motivation

The useState hook is a convenient method to provide a temporary component state. It is very common to invoke the useState hook for added interactivity of a button or other visual components. There are 3 main concerns when using the useState hook.

  • Is the state necessary?
  • Is the state located at the right level in the component tree?
  • Is the state updated correctly?

The first 2 questions can be answered with some considerations on the choice of state management solutions and whether to use composition vs inheritance. They are somewhat discussed here in Composition vs Inheritance , Lifting State up and Thinking in React. I would like to share a little bit on updating the state properly (which I failed to do so in a new feature that I was implementing).

Encounter

I was adding a search + sort feature to display a list of quizzes in my side project done in Next.js (which is practically React). To keep track of the search input and the sorting option, I used two useState hooks. I have extracted out the related piece of code below, style related classes and components are left out for brevity.

In gist :

  • QuizList component receives a list of quizzes
  • query keeps track of the search input
  • sortBy keeps track of the sorting option
  • filteredQuizzes keeps track of the manipulated copy of quizzes to be displayed
const QuizList = ({ quizzes }: { quizzes: Quiz[] }): JSX.Element => {
const [query, setQuery] = useState('')
const [sortBy, setSortBy] = useState('title')
const [filteredQuizzes, setFilteredQuizzes] = useState<Quiz[]>([])
useEffect(() => {
let result = quizzes
if (sortBy === 'title') {
result.sort((a, b) => a.title.localeCompare(b.title))
} else {
result.sort((a, b) => a.week - b.week)
}
if (query.trim() === '') {
setFilteredQuizzes(result)
} else {
setFilteredQuizzes(
result.filter((quiz) => quiz.title.toLowerCase().includes(query.toLowerCase()))
)
}
}
}, [query, quizzes, sortBy])
return (
<div>
<Search query={query} setQuery={setQuery} />
<RadioGroup onChange={setSortBy} value={sortBy}>
<Stack direction="row">
<span>Sort by:</span>
<Radio value="title">Title</Radio>
<Radio value="week">Week</Radio>
</Stack>
</RadioGroup>
<div>
{filteredQuizzes.map((quiz) => {
return <QuizItemCard key={quiz.id} quiz={quiz} />
})}
</div>
</div>
)

This is how it looks like: before

I must say that the few times I decided to violate the principle of immutability and start my code by declaring variables with let instead of const, they always surprised me with a hidden bug. The above code seemed to work but there was a strange lag when I toggle between the options for sorting by 'Title' vs sorting by 'Week'. In fact, the sorting seemed to be erroneous.

The logic of the code is as follows:

  • check the sorting option, if it is sort by title, sort the list of quizzes in place with a comparison on the titles. Else, sort the list by the week attribute of each quiz
  • then check for search input and keep only the ones that include the search input

I suspected that the inconsistent and delayed sorting behavior was due to the mutation of quiz list in place and the wrong use of setFilteredQuizzes. Coincidentally, the article that I planned to write this week was related to the official React.js FAQ and reading its section on Component State gave me an idea on how to fix the state update.

Fix

As mentioned in the Component State section of the FAQ, setState operations are not immediately invoked and inappropriate usage will result in unintended consequences. Quoting an example code snippet directly from the document:

incrementCount() {
this.setState((state) => {
// Important: read `state` instead of `this.state` when updating.
return {count: state.count + 1}
});
}

handleSomething() {
// Let's say `this.state.count` starts at 0.
this.incrementCount();
this.incrementCount();
this.incrementCount();

// If you read `this.state.count` now, it would still be 0.
// But when React re-renders the component, it will be 3.
}

Because of the above fact, it helps to know that the setState operation can accept either the updated state or a update function that will take in the previous state and return the updated state.

Thus my solution is simple: use the spread operator to make a copy of the list, sort it and return it within an update function. By making updates within the update functions, the latest updated list will be used whenever filteredQuizzes is referenced. Other alternative solutions include updating the filteredQuizzes in event handlers of the sorting radio buttons instead of keeping track of the sorting state.

The fixed version looks like this: after

And the code as follows:

const QuizList = ({ quizzes }: { quizzes: Quiz[] }): JSX.Element => {
const [query, setQuery] = useState('')
const [sortBy, setSortBy] = useState('title')
const [filteredQuizzes, setFilteredQuizzes] = useState<Quiz[]>([])
useEffect(() => {
if (sortBy === 'title') {
setFilteredQuizzes(() => [...quizzes].sort((a, b) => a.title.localeCompare(b.title)))
} else {
setFilteredQuizzes(() => [...quizzes].sort((a, b) => a.week - b.week))
)
}
if (query.trim() === '') {
setFilteredQuizzes((filteredQuizzes) => filteredQuizzes)
} else {
setFilteredQuizzes((filteredQuizzes) =>
filteredQuizzes.filter((quiz) => quiz.title.toLowerCase().includes(query.toLowerCase()))
)
}
}, [query, quizzes, sortBy])
return (
<div>
<Search query={query} setQuery={setQuery} />
<RadioGroup onChange={setSortBy} value={sortBy}>
<Stack direction="row">
<span>Sort by:</span>
<Radio value="title">Title</Radio>
<Radio value="week">Week</Radio>
</Stack>
</RadioGroup>
<div>
{filteredQuizzes.map((quiz) => {
return <QuizItemCard key={quiz.id} quiz={quiz} />
})}
</div>
</div>
)

Conclusion

As someone who is guilty of being lazy, my initial response to the above bug after some experimentation was to remove the sorting option and just sort the quizzes coming in. Due to the fact that I set out to write this article, and that I was inspired by Kent C. Dodds to read the React FAQ, I avoided taking the easy way out and spent some more time thinking about this little problem.

I guess the moral of the story is:

  • Don't always take the easy way out
  • Give problems a second thought
  • Start writing articles