The Most Common Mistakes When Using React
Updated on · 12 min read|React is a popular library for building user interfaces, and it offers various features to help developers create complex applications with ease. However, as with any technology, it's easy to make mistakes while using React, especially when you're new to it. In this article, we will explore some of the most common mistakes that developers make when using React, React hooks, managing state, and rendering components. By understanding these mistakes and learning how to fix them, you can improve your application's performance, maintainability, and overall quality.
Setting incorrect value types for the initial state
A common mistake is setting the initial state value of an object or array to null
or an empty string, and then attempting to access the properties of that value during render. Similarly, not providing default values for nested objects and trying to access them in the render method or other component methods can cause issues.
Consider a UserProfile
component where we fetch the user's data from an API and display a greeting that includes the user's name.
jsximport { useEffect, useState } from "react"; export function UserProfile() { const [user, setUser] = useState(null); useEffect(() => { fetch("/api/profile").then((data) => { setUser(data); }); }, []); return ( <div> <div>Welcome, {user.name}</div> </div> ); }
jsximport { useEffect, useState } from "react"; export function UserProfile() { const [user, setUser] = useState(null); useEffect(() => { fetch("/api/profile").then((data) => { setUser(data); }); }, []); return ( <div> <div>Welcome, {user.name}</div> </div> ); }
Attempting to render this component will result in an error: Cannot read property 'name' of null.
A similar issue occurs when setting the initial state value to an empty array and then trying to access the n-th item from it. While the data is being fetched by an API call, the component renders with the provided initial state. Attempting to access a property on a null
or undefined
element will cause an error. Consequently, it's crucial to ensure the initial state closely represents the updated state. In our example, the correct state initialization should be:
jsximport { useEffect, useState } from "react"; export function UserProfile() { const [user, setUser] = useState({ name: "" }); useEffect(() => { fetch("/api/profile").then((data) => { setUser(data); }); }, []); return ( <div> <div>Welcome, {user.name}</div> // Renders without errors </div> ); }
jsximport { useEffect, useState } from "react"; export function UserProfile() { const [user, setUser] = useState({ name: "" }); useEffect(() => { fetch("/api/profile").then((data) => { setUser(data); }); }, []); return ( <div> <div>Welcome, {user.name}</div> // Renders without errors </div> ); }
This approach works in the simple example above. However, providing defaults for state values might not always be practical, particularly when dealing with large objects. In such cases, it is possible to postpone rendering of the component until the data has been fetched. One way to accomplish this is by initializing the component with a loading state and displaying a loader until the data is fetched and ready to be rendered (or until an error occurs during data fetching).
jsximport { useEffect, useState } from "react"; export function UserProfile() { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { fetch("/api/profile") .then((data) => { setUser(data); }) .catch((error) => { // Handle the error }) .finally(() => { setIsLoading(false); }); }, []); if (isLoading) { return <div>Loading...</div>; } return ( <div> <div>Welcome, {user?.name}</div> </div> ); }
jsximport { useEffect, useState } from "react"; export function UserProfile() { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { fetch("/api/profile") .then((data) => { setUser(data); }) .catch((error) => { // Handle the error }) .finally(() => { setIsLoading(false); }); }, []); if (isLoading) { return <div>Loading...</div>; } return ( <div> <div>Welcome, {user?.name}</div> </div> ); }
We also use an optional chaining operator to prevent an error in case the data returned is null
. However, additional error handling will still be necessary, as in such a case the component will display the greeting without a name.
Note that we reset the loading state in the finally
method, which removes it regardless of whether the fetch operation was successful or not. This ensures the loading state is always reset after the asynchronous operation has been completed.
If you're looking for some pointers on how to become a self-taught developer, you may find this post helpful: Tips on Becoming a Self-taught Developer.
Directly modifying the state
React's state is considered immutable and should not be directly changed. Instead, use the setter function from the useState
hook. Furthermore, when updating non-primitive values such as objects or arrays stored in the state, we need to provide a different object reference (also known as a pointer) to them. Otherwise, React will not recognize that the state has changed and won't update the UI.
Working with simple arrays
Consider an example where we have a todo list with the ability to add new items. The AddItem
component lets users enter text for the todo item and save it.
jsxconst TodoList = () => { const [items, setItems] = useState([]); const onItemAdd = (item) => { items.push(item); setItems(items); }; return ( <div> {items.length > 0 ? ( <div> Your todo items <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> ) : ( <div>No items</div> )} <AddItem onItemAdd={onItemAdd} /> </div> ); };
jsxconst TodoList = () => { const [items, setItems] = useState([]); const onItemAdd = (item) => { items.push(item); setItems(items); }; return ( <div> {items.length > 0 ? ( <div> Your todo items <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> ) : ( <div>No items</div> )} <AddItem onItemAdd={onItemAdd} /> </div> ); };
In this example, when we type a new item and save it, it doesn't appear in the list of todo items. This issue occurs because Array#push mutates the original array. When we save it to the state, it still points to the same memory location as before, so React doesn't recognize any changes.
To resolve this, instead of modifying the items
array in place by adding a new item, we need to create a new instance of the items
array with the new item added. This can be achieved using the spread syntax.
jsxconst TodoList = () => { const [items, setItems] = useState([]); const onItemAdd = (item) => { setItems([...items, item]); }; return ( <div> {items.length > 0 ? ( <div> Your todo items <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> ) : ( <div>No items</div> )} <AddItem onItemAdd={onItemAdd} /> </div> ); };
jsxconst TodoList = () => { const [items, setItems] = useState([]); const onItemAdd = (item) => { setItems([...items, item]); }; return ( <div> {items.length > 0 ? ( <div> Your todo items <ul> {items.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> ) : ( <div>No items</div> )} <AddItem onItemAdd={onItemAdd} /> </div> ); };
Now, when we add a new item, it's properly displayed in the UI. This illustrates the importance of using immutable object or array manipulation methods when updating state values.
Immutable updating of an array of objects
Creating a new array is relatively simple when it consists of only string or number values. But what about more complex cases, such as arrays of objects?
Consider an example where we need to update the checked
field of a particular object in an array based on the state of a checkbox.
javascriptconst updateItemList = (event, index) => { itemList[index].checked = event.target.checked; setItemList(itemList); };
javascriptconst updateItemList = (event, index) => { itemList[index].checked = event.target.checked; setItemList(itemList); };
This approach, once again, won't work because we directly modify the listFeatures
array. We can't use the spread syntax in the same way as in the previous example. However, we can still use it to create a copy of the original array and modify that copy.
javascriptconst updateItemList = (event, index) => { const newList = [...itemList]; newList[index].checked = event.target.checked; setItemList(newList); };
javascriptconst updateItemList = (event, index) => { const newList = [...itemList]; newList[index].checked = event.target.checked; setItemList(newList); };
This method works because we create a new array, and even though we directly mutate its element at a specific index, a new array reference is sufficient for React to detect the change and update the UI with the new list.
However, another issue arises with this approach. When creating a copy of an array using the spread syntax (similarly with concat or slice methods), the values inside the array are copied only one level deep, known as a shallow copy. Suppose our items
array has nested objects with multiple levels, and this items
array is also used in other parts of the application (e.g., we store items[0]
as a selected item). If we modify the property of an object in this shallow copy, it will affect all other instances of this object in the app, even though we haven't explicitly updated them.
To avoid such issues, which can be challenging to debug, it's better to use a proper immutability approach when modifying the state. Depending on the object depth, we can use the map
method to create a new array and the objects within it.
javascriptconst updateFeaturesList = (event, index) => { const newFeatures = features.map((feature, idx) => { if (idx === index) { return { ...feature, checked: event.target.checked }; } return { ...feature }; }); setListFeatures(newFeatures); };
javascriptconst updateFeaturesList = (event, index) => { const newFeatures = features.map((feature, idx) => { if (idx === index) { return { ...feature, checked: event.target.checked }; } return { ...feature }; }); setListFeatures(newFeatures); };
By using map
and the object spread, we ensure that the original state items remain unchanged.
An alternative method is to make a deep copy of the items
array using the structuredClone API, which is now supported in all major browsers.
javascriptconst updateItemList = (event, index) => { const newList = structuredClone(itemList); newList[index].checked = event.target.checked; setItemList(newList); };
javascriptconst updateItemList = (event, index) => { const newList = structuredClone(itemList); newList[index].checked = event.target.checked; setItemList(newList); };
structuredClone
creates a deep copy of the value, allowing us to safely mutate the new array.
While both methods offer ways to immutably update the state, they may not be optimal in terms of speed and memory usage, especially with large data sizes. In such cases, consider using a specialized library for working with immutable data, such as Immer.js.
Pass by value vs. pass by reference
Understanding the differences between pass by value and pass by reference is crucial for optimizing component re-rendering and state management in React. It's essential to note that JavaScript does not truly support pass by reference, and all data is passed by value.
What is often referred to as "pass by reference" is actually an object reference or memory address pointer. When an object reference is passed to a function, the function receives a copy of the object's memory address, meaning two different variables on two different stack frames point to the same memory location. This allows the function to modify the object's properties, but it cannot make the original reference point to a new object, which is what "pass by reference" means in languages that truly support it, like C++. To dive deeper into this topic, there's an excellent article: Is JavaScript Pass by Reference?.
Understanding the implications of working with object references is particularly important in React because it internally relies on same-value equality when updating component state or props. React uses the memory address to determine if an object or array in the state or props has changed and if a re-render is necessary. That's why adopting immutable data handling practices, such as creating new objects or arrays when updating the state, is crucial to avoid unintended side effects and unnecessary re-renders.
Forgetting that setting state is asynchronous
One of the most common mistakes when using React is attempting to access a state value immediately after setting it. For example, consider the following code snippet:
javascriptimport { useState } from "react"; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); console.log(count); // 0 }; }
javascriptimport { useState } from "react"; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); console.log(count); // 0 }; }
When setting a new value, it doesn't occur immediately. Typically, it's performed on the next available render or batched to optimize performance. Consequently, accessing a state value after setting it might not reflect the latest updates. In class-based React components, this issue can be resolved by using an optional second argument for setState
, which is a callback function called after the state has been updated with its latest values.
javascriptincrement = () => { this.setState({ count: this.state.count + 1 }, () => { console.log(this.state.count); // Updated state value }); };
javascriptincrement = () => { this.setState({ count: this.state.count + 1 }, () => { console.log(this.state.count); // Updated state value }); };
However, hooks work differently, as the setter function from useState
doesn't have a second callback argument similar to setState
. In the past, it was recommended to use the useEffect
hook to access the changed state value.
javascriptimport { useEffect, useState } from "react"; function Counter() { const [count, setCount] = useState(0); useEffect(() => { console.log(count); // Will be called when the value of count changes }, [count]); const increment = () => { setCount(count + 1); }; }
javascriptimport { useEffect, useState } from "react"; function Counter() { const [count, setCount] = useState(0); useEffect(() => { console.log(count); // Will be called when the value of count changes }, [count]); const increment = () => { setCount(count + 1); }; }
In React 18, it's recommended to reduce the number of useEffect
calls inside components. For this specific case, a better approach would be to perform the calculation in the event handler.
javascriptimport { useState } from "react"; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); console.log(count + 1); }; }
javascriptimport { useState } from "react"; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); console.log(count + 1); }; }
It's important to note that setting a state is not asynchronous in a way that it returns a promise. So, adding async/await
to it or using then
won't work, which is another common mistake.
Incorrectly relying on the current state value for calculating the next state
This issue is closely related to the one discussed above, though it occurs less frequently. Take a look at the following code snippet:
javascriptconst [count, setCount] = useState(0); const incrementTwice = () => { setCount(count + 1); setCount(count + 1); };
javascriptconst [count, setCount] = useState(0); const incrementTwice = () => { setCount(count + 1); setCount(count + 1); };
After executing this function, we might expect the state value for count
to be 2
. However, it is actually 1
. This is because React batches state updates whenever possible to prevent unnecessary re-renders. Although setCount
is called twice in this example, the value of count
during both calls remains 0
, so the final result is always 1
, no matter the number of setCount
calls.
A correct way to handle this case is to use the updater function inside of setCount
.
javascriptconst [count, setCount] = useState(0); const increment = () => { setCount((c) => c + 1); setCount((c) => c + 1); };
javascriptconst [count, setCount] = useState(0); const increment = () => { setCount((c) => c + 1); setCount((c) => c + 1); };
In this case, React will always use the latest value of count
and update it correctly. This is also discussed in more detail in the React documentation.
Including non-primitive objects or values in hook dependency arrays
Another common mistake is including objects, arrays, or other non-primitive values in a hook's dependency array. Consider the following code snippet:
javascriptfunction FeatureList() { const defaultFeatures = ["feature1", "feature2"]; const isDefault = useCallback( (feature) => defaultFeatures.includes(feature), [defaultFeatures], ); }
javascriptfunction FeatureList() { const defaultFeatures = ["feature1", "feature2"]; const isDefault = useCallback( (feature) => defaultFeatures.includes(feature), [defaultFeatures], ); }
In this example, we want to memoize the isDefault
function to prevent it from being computed on each render; however, there's an issue with this code.
When we pass an array as a dependency, React stores only its object reference (memory address pointer) and compares it to the previous object reference of the array. However, since the features
array is declared inside the component, it is recreated on every render. This means that its reference will be new every time and not equal to the one tracked by useCallback
. As a result, the callback function will run on every render, even if the array hasn't changed.
One way to fix this is to wrap the defaultFeatures
array in the useMemo
hook.
javascriptconst defaultFeatures = useMemo(() => ["feature1", "feature2"], []);
javascriptconst defaultFeatures = useMemo(() => ["feature1", "feature2"], []);
However, in most cases, this approach is overkill and can easily lead to dependency management spiraling out of control. For example, if defaultFeatures
itself depends on some other variables, and so on.
A better solution, in this case, would be to simply move the variable declaration outside the component so that it is not recreated on every render.
javascriptconst defaultFeatures = ["feature1", "feature2"]; function FeatureList() { const isDefault = useCallback( (feature) => defaultFeatures.includes(feature), [], ); }
javascriptconst defaultFeatures = ["feature1", "feature2"]; function FeatureList() { const isDefault = useCallback( (feature) => defaultFeatures.includes(feature), [], ); }
Excessively using useCallback and useMemo
In the previous example, we wrapped a function in the useCallback
hook to prevent its result from being recomputed on every render. It may seem like we're improving the performance of our application by doing this; however, this is not always the case. Excessive use of hooks like useCallback
and useMemo
can be counterproductive. Wrapping every function with useCallback
or every variable with useMemo
can create unnecessary overhead and actually decrease performance. For example, if the dependency is a very large array, the calculations for dependency changes might take longer than an extra render or a function call.
Instead, we should use these hooks only when necessary. For instance, we can use useCallback
to memoize a function that is passed down as a prop to child components. Similarly, useMemo
can be used to cache a value that is expensive to compute. We should avoid using these hooks for trivial functions and values.
Adding separate onChange handlers for each input
When building forms in React, it's common to use the onChange
event to update the component's state. One mistake that can lead to bloated code is adding separate onChange
handlers for each input field. This can quickly become cumbersome, especially when dealing with complex forms that have many inputs.
Let's imagine we have this ProfileForm
component.
jsxfunction ProfileForm() { const [formData, setFormData] = useState({ firstName: "", lastName: "", email: "", }); const onFirstNameChange = (event) => { setFormData({ ...formData, firstName: event.target.value }); }; const onLastNameChange = (event) => { setFormData({ ...formData, lastName: event.target.value }); }; const onEmailChange = (event) => { setFormData({ ...formData, email: event.target.value }); }; return ( <form> <label> First name: <input type="text" value={formData.firstName} onChange={onFirstNameChange} /> </label> <label> Last name: <input type="text" value={formData.lastName} onChange={onLastNameChange} /> </label> <label> Email: <input type="email" value={formData.email} onChange={onEmailChange} /> </label> </form> ); }
jsxfunction ProfileForm() { const [formData, setFormData] = useState({ firstName: "", lastName: "", email: "", }); const onFirstNameChange = (event) => { setFormData({ ...formData, firstName: event.target.value }); }; const onLastNameChange = (event) => { setFormData({ ...formData, lastName: event.target.value }); }; const onEmailChange = (event) => { setFormData({ ...formData, email: event.target.value }); }; return ( <form> <label> First name: <input type="text" value={formData.firstName} onChange={onFirstNameChange} /> </label> <label> Last name: <input type="text" value={formData.lastName} onChange={onLastNameChange} /> </label> <label> Email: <input type="email" value={formData.email} onChange={onEmailChange} /> </label> </form> ); }
We can see that the only variable part in all the onChange
handlers is the name of the field. We could have used a single onChange
handler by creating a curried function and then calling it with the name of the field:
jsxconst onInputChange = (name) => (event) => { setFormData({ ...formData, [name]: event.target.value }); }; // ... <input type="text" name="firstName" value={formData.firstName} onChange={onInputChange("firstName")} />;
jsxconst onInputChange = (name) => (event) => { setFormData({ ...formData, [name]: event.target.value }); }; // ... <input type="text" name="firstName" value={formData.firstName} onChange={onInputChange("firstName")} />;
However, there's a better way. Instead, we can use a single onChange
handler and update the state based on the name
attribute of the input and its value.
jsxfunction ProfileForm() { const [formData, setFormData] = useState({ firstName: "", lastName: "", email: "", }); const onInputChange = (event) => { const { name, value } = event.target; setFormData({ ...formData, [name]: value }); }; return ( <form> <label> First name: <input type="text" name="firstName" value={formData.firstName} onChange={onInputChange} /> </label> <label> Last name: <input type="text" name="lastName" value={formData.lastName} onChange={onInputChange} /> </label> <label> Email: <input type="email" name="email" value={formData.email} onChange={onInputChange} /> </label> </form> ); }
jsxfunction ProfileForm() { const [formData, setFormData] = useState({ firstName: "", lastName: "", email: "", }); const onInputChange = (event) => { const { name, value } = event.target; setFormData({ ...formData, [name]: value }); }; return ( <form> <label> First name: <input type="text" name="firstName" value={formData.firstName} onChange={onInputChange} /> </label> <label> Last name: <input type="text" name="lastName" value={formData.lastName} onChange={onInputChange} /> </label> <label> Email: <input type="email" name="email" value={formData.email} onChange={onInputChange} /> </label> </form> ); }
This approach simplifies and maintains our code by enabling us to write a single function to handle all input changes. It doesn't have to be the name
attribute necessarily. If your input elements already have an assigned id
, it can be used instead.
If you're curious about working with forms in React, you may find this article helpful: Managing Forms with React Hook Form.
Unnecessarily using useEffect
With the introduction of concurrent rendering in React 18, it's more important than ever to use hooks such as useEffect
correctly. Using useEffect
unnecessarily can cause performance issues, side effects, and hard-to-debug problems.
We should only use useEffect
when we need to perform side effects that impact the outside world, like fetching data from a server, subscribing to an event, or updating the DOM. These side effects can often be asynchronous and may occur unpredictably, making it necessary to manage them in our components.
However, when we only need to perform side effects that influence the component's internal state or don't affect the outside world, we can use other hooks or plain JavaScript instead of useEffect
. The updated React documentation provides a comprehensive list of situations where useEffect
may not be required and how it can be replaced.
Conclusion
In conclusion, React is a powerful library that can help you create robust, performant, and maintainable web applications. However, like any tool, it's essential to understand its nuances and best practices to get the most out of it. By avoiding the common mistakes discussed in this article, you can ensure that your React codebase is efficient, reliable, and easy to maintain.
Remember that becoming a skilled React developer requires continuous learning, practice, and refinement of your skills. So keep exploring new concepts, experimenting with different approaches, and building new projects. With patience and perseverance, you can master React and use it to create amazing applications that delight your users and solve real-world problems.
References and resources
- Can I use?: structuredClone
- GitHub: SetStateAction returned from useState hook does not accept a second callback argument
- Immer.js
- Immutable Array Operations In JavaScript: Introducing ToSorted, ToSpliced, and ToReversed
- Is JavaScript Pass by Reference?
- MDN: Array.prototype.concat
- MDN: Array.prototype.push
- MDN: Array.prototype.slice
- MDN: Optional chaining
- MDN: Same-value equality using Object.is()
- MDN: Spread syntax
- MDN: structuredClone
- React documentation: You Might Not Need an Effect