useState
is the React hook that you use most often. It's everywhere. But so are some common mistakes.
You probably have experienced some of them (even if you didn't realize it): redundant, duplicate, or contradicting state. Some of those may force you to have a useEffect that is actually obsolete. And all this combined can become a big trap of unmaintainable and hard-to-read code.
Knowing about these pitfalls helps you
Not to forget, you won’t easily fall into an embarrassing trap in one of those coding assignments in the hiring process. The problem is: you first need to become aware of the potential problems around useState
in order to avoid them.
So on this page, let's have a look at the most common pitfalls when it comes to state in React. For each of them, you'll see
After reading this article and working through the exercises, you'll likely look at your own code in a different way.
State variables that aren’t necessary are one of the most common problems in code written by Junior developers. You can typically find them whenever one state depends on other state variables.
A simple example is probably the best way to explain the situation. So let’s dive right in.
Here is a simple component that allows a user to edit their first and last name. Based on the input values their full name is rendered.
Can you spot the redundant state?
import { useState } from "react";function RedundantState() {const [firstName, setFirstName] = useState("");const [lastName, setLastName] = useState("");const [fullName, setFullName] = useState("");const onChangeFirstName = (event) => {setFirstName(event.target.value);setFullName(`${event.target.value} ${lastName}`);};const onChangeLastName = (event) => {setLastName(event.target.value);setFullName(`${firstName} ${event.target.value}`);};return (<><form><inputvalue={firstName}onChange={onChangeFirstName}placeholder="First Name"/><inputvalue={lastName}onChange={onChangeLastName}placeholder="Last Name"/></form><div>Full name: {fullName}</div></>);}
If you said fullName
is redundant: Congrats, you’re right.
Your first instinct might say: By updating e.g. the firstName
and fullName
states directly after one another we cause an additional render cycle.
const onChangeFirstName = (event) => {setFirstName(event.target.value);setFullName(`${event.target.value} ${lastName}`);};
But as of React 18 state updates are batched. So you don’t see separate renders for each state update.
Note: the additional render in the screenshot below happens only in development.
So in most cases, there isn’t much of a difference performance-wise. The problem is rather the maintainability and risk of introducing bugs. For example, look at the change handlers again:
const onChangeFirstName = (event) => {setFirstName(event.target.value);setFullName(`${event.target.value} ${lastName}`);};const onChangeLastName = (event) => {setLastName(event.target.value);setFullName(`${firstName} ${event.target.value}`);};
Each time we update either the first or last name we have to remember to update fullName
as well. In a more complex scenario that can easily be missed. Thus the code is harder to refactor and the risk of introducing bugs is increased.
As mentioned, in most cases you don’t need to worry about performance. But if you have to derive a variable from state that involves large arrays or heavy calculations, you can simply reach for the useMemo hook.
The fullName
state is simply the first and last name combined. We can directly build it from the firstName
and lastName
state variables.
export function RedundantState() {const [firstName, setFirstName] = useState("");const [lastName, setLastName] = useState("");const fullName = `${firstName} ${lastName}`;...return (<><form>...</form><div>Full name: {fullName}</div></>);}
We don’t even need the temporary variable here but can directly render the firstName
and lastName
into the JSX.
export function RedundantState() {const [firstName, setFirstName] = useState("");const [lastName, setLastName] = useState("");...return (<><form>...</form><div>Full name: {firstName} {lastName}</div></>);}
Ok, got it. We should watch out for redundant state that we can replace with simple variables derived from other state. But what’s the problem here?
Data that is duplicated in multiple state variables is another problem. You typically encounter it when transforming, sorting, or filtering (API) data. Another common case is selecting items as in the example below.
Here’s a simple component that renders a list of items. The user can open an item in an (imaginary) modal by clicking on the corresponding button.
The code below contains a typical mistake that you can often see. Can you find it?
import { useState } from "react";// const items = [// {// id: "item-1",// text: "Item 1",// },// ...// ]function DuplicateState({ items }) {const [selectedItem, setSelectedItem] = useState();const onClickItem = (item) => {setSelectedItem(item);};return (<>{selectedItem && <Modal item={selectedItem} />}<ul>{items.map((row) => (<li key={row.id}>{row.text}<button onClick={() => onClickItem(row)}>Open</button></li>))}</ul></>);}
The problem is that the complete item is copied into the state.
This again doesn’t seem like a big change. So what’s the deal?
The problem with the duplicated data in the original code is that it violates the Single Source Of Truth principle. In fact, we have two sources of truth once the user selects any of the items: The selectedItem
state and the corresponding entry in the items
array.
Now imagine that the user should be able to edit the item inside the modal. This could look like this:
items
array.DuplicateState
component?This is where the problem starts. The selectedItem
state would still contain the old data. It would be out of sync. You can imagine that this can become a nasty bug in a more complex situation.
Of course, we can keep the selectedItem
state in sync. But we would need to listen to changes in the items
array with a useEffect
. And that brings us to the next section.
A simpler solution is to only track the selected id. As you can see the solution is pretty similar to the one in the “Redundant State” section: We simply derive the selectedItem
variable from its id.
// const items = [// {// id: "item-1",// text: "Item 1",// },// ...// ]function DuplicateState({ items }) {const [selectedItemId, setSelectedItemId] = useState();const selectedItem = items.find(({ id }) => id === selectedItemId);const onClickItem = (itemId) => {setSelectedItemId(itemId);};return (<>{selectedItem && <Modal item={selectedItem} />}<ul>{items.map((row) => (<li key={row.id}>{row.text}<button onClick={() => onClickItem(row.id)}>Open</button></li>))}</ul></>);}
Another common problem with state in React is listening to changes of variables with useEffect
. It’s so easy to forget that a fellow developer had to point out to me that I made this mistake myself.
Let’s take the (slightly adjusted) example from the previous section.
As you can see the component has now a useEffect
to sync the selectedItem
state when the items
array changes.
import { useEffect, useState } from "react";// const items = [// {// id: "item-1",// text: "Item 1",// },// ...// ]function DuplicateState({ items }) {const [selectedItem, setSelectedItem] = useState();useEffect(() => {if (selectedItem) {setSelectedItem(items.find(({ id }) => id === selectedItem.id));}}, [items]);const onClickItem = (item) => {setSelectedItem(item);};return (<>{selectedItem && <Modal item={selectedItem} />}<ul>{items.map((row) => (<li key={row.id}>{row.text}<button onClick={() => onClickItem(row)}>Open</button></li>))}</ul></>);}
This code should work properly and keep the selectedItem
state in sync. But doesn’t it feel hacky?
There are multiple problems with this approach:
useEffect
isn’t easy to read and understand. So the fewer of them we have the better.useEffect
causes an additional render. This usually isn’t a big problem performance-wise but needs to be considered.selectedItem
state and the items
prop. This is easy to miss when reading or changing the code.useEffect
at the right time. You can often see other workarounds with this pattern e.g. to avoid running the code on the first render. Here is an example:function DuplicateState({ items }) {const [selectedItem, setSelectedItem] = useState();const firstRender = useRef(true);useEffect(() => {if (firstRender.current) {firstRender.current = false;return;}setSelectedItem(items.find(({ id }) => id === selectedItem.id));}, [items]);...
The takeaway here: If you want to use a useEffect
or see it in another dev’s code ask yourself if it’s really required. Maybe it can be avoided by de-duplicating or deriving state as shown in the previous sections.
You might have guessed: The solution from the previous section also helps us to remove the useEffect
. If we only store the selected item's ID instead of the whole object there’s nothing to be synced.
import { useState } from "react";// const items = [// {// id: "item-1",// text: "Item 1",// },// ...// ]function DuplicateState({ items }) {const [selectedItemId, setSelectedItemId] = useState();const selectedItem = items.find(({ id }) => id === selectedItemId);const onClickItem = (id) => {setSelectedItem(id);};return (<>{selectedItem && <Modal item={selectedItem} />}<ul>{items.map((row) => (<li key={row.id}>{row.text}<button onClick={() => onClickItem(row.id)}>Open</button></li>))}</ul></>);}
A common problem related to the previous section is reacting to changes in a state variable with useEffect
. The solution is slightly different though.
Here is a component that shows a product. The user can show or hide the product details by clicking a button. Whenever the details are shown or hidden we trigger an action (in this case tracking an event in our imaginary analytics tool).
import { useEffect, useState } from "react";function ProductView({ name, details }) {const [isDetailsVisible, setIsDetailsVisible] = useState(false);useEffect(() => {trackEvent({ event: "Toggle Product Details", value: isDetailsVisible });}, [isDetailsVisible]);const toggleDetails = () => {setIsDetailsVisible(!isDetailsVisible);};return (<div>{name}<button onClick={toggleDetails}>Show details</button>{isDetailsVisible && <ProductDetails {...details} />}</div>);}
The useEffect
in this case listens to changes in the isDetailsVisible
variable and runs the tracking code accordingly.
By the way, the above code contains a bug. It’s really easy to overlook. You can find an explanation in “The Problem” section below.
Just like in the previous section, there are a few problems:
useEffect
often isn’t easy to understand.trackEvent
during the initial render.trackEvent
being run because isDetailsVisible
changes. But the real cause is that the user pressed the “Show details” button.In many cases, a useEffect
that listens to changes in a state variable can be removed. Often, we can place the effect next to the code that updates the state in the first place. Here we move trackEvent(...)
inside the toggleDetails
function.
function ProductView({ name, details }) {const [isDetailsVisible, setIsDetailsVisible] = useState(false);const toggleDetails = () => {setIsDetailsVisible(!isDetailsVisible);trackEvent({ event: "Toggle Product Details", value: !isDetailsVisible });};return (<div>{name}<button onClick={toggleDetails}>Show details</button>{isDetailsVisible && <ProductDetails {...details} />}</div>);}
When you work with multiple state variables that depend on each other you can easily produce an overall component state that shouldn’t be allowed. It’s probably easier to show this in code.
Here we have a basic data fetching example. The component can be in different states: either it’s loading data, an error occurred, or the data was fetched successfully.
export function ContradictingState() {const [data, setData] = useState(null);const [isLoading, setIsLoading] = useState(true);const [error, setError] = useState(null);useEffect(() => {setIsLoading(true);setError(null);fetchData().then((data) => {setData(data);setIsLoading(false);}).catch((error) => {setIsLoading(false);setData(null);setError(error);});}, []);...
The problem with this approach is that we can end up in a contradicting state if we’re not careful. In the above example, we e.g. might forget to set isLoading
to false
when an error occurs.
It’s also hard to understand, what combinations of state variables are allowed. In the above example, we could have 8 different component states in theory. But you can’t really see immediately what state combinations really exist.
Just in case you’re wondering where the 8 state combinations come from:
data
could benull
or an object,isLoading
could betrue
orfalse
, anderror
could also benull
or an object. So 2 2 2 = 8.
Multiple state variables depending on each other is a common scenario to introduce useReducer
instead of useState
.
const initialState = {data: [],error: null,isLoading: false};function reducer(state, action) {switch (action.type) {case "FETCH":return {...state,error: null,isLoading: true};case "SUCCESS":return {...state,error: null,isLoading: false,data: action.data};case "ERROR":return {...state,isLoading: false,error: action.error};default:throw new Error(`action "${action.type}" not implemented`);}}export function NonContradictingState() {const [state, dispatch] = useReducer(reducer, initialState);useEffect(() => {dispatch({ type: "FETCH" });fetchData().then((data) => {dispatch({ type: "SUCCESS", data });}).catch((error) => {dispatch({ type: "ERROR", error });});}, []);...
This is a lot less overhead on our brains. We can immediately see that we have 3 actions and 4 possible component states (for “FETCH”, “SUCCESS”, “ERROR”, and the initial state).
The final common problem we mention here is a state of (deeply) nested objects. If you simply render the data it might not be a problem at all. But as soon as you start updating nested items you’re in for some trouble.
Here we have a component that renders deeply nested comments. The JSX doesn’t matter much here but imagine the updateComment
callback being attached to a button or input.
function NestedComments() {const [comments, setComments] = useState([{id: "1",text: "Comment 1",children: [{id: "11",text: "Comment 1 1"},{id: "12",text: "Comment 1 2"}]},{id: "2",text: "Comment 2"},{id: "3",text: "Comment 3",children: [{id: "31",text: "Comment 3 1",children: [{id: "311",text: "Comment 3 1 1"}]}]}]);const updateComment = (id, text) => {// this gets complicated};...
The problem with nested state in React is that we have to update it in an immutable way otherwise the component doesn’t re-render.
The hard-coded update logic for a deeply nested comment in the above example would look something like this.
const updateComment = (id, text) => {setComments([...comments.slice(0, 2),{...comments[2],children: [{...comments[2].children[0],children: [{...comments[2].children[0].children[0],text: "New comment 311"}]}]}]);};
But making this dynamic gets really complicated.
Instead of a deeply nested state, it’s much easier to work with a flat data structure. We can reference the items to each other via their IDs. This could look like this.
function FlatCommentsRoot() {const [comments, setComments] = useState([{id: "1",text: "Comment 1",children: ["11", "12"],},{id: "11",text: "Comment 1 1"},{id: "12",text: "Comment 1 2"},{id: "2",text: "Comment 2",},{id: "3",text: "Comment 3",children: ["31"],},{id: "31",text: "Comment 3 1",children: ["311"]},{id: "311",text: "Comment 3 1 1"}]);const updateComment = (id, text) => {const updatedComments = comments.map((comment) => {if (comment.id !== id) {return comment;}return {...comment,text};});setComments(updatedComments);};...
Now it’s as easy as finding the correct item by its ID and replacing it in the array.