The Most Common Mistakes When Using React

Updated on · 12 min read
The Most Common Mistakes When Using React

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.

jsx
import { 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> ); }
jsx
import { 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:

jsx
import { 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> ); }
jsx
import { 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).

jsx
import { 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> ); }
jsx
import { 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.

jsx
const 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> ); };
jsx
const 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.

jsx
const 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> ); };
jsx
const 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.

javascript
const updateItemList = (event, index) => { itemList[index].checked = event.target.checked; setItemList(itemList); };
javascript
const 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.

javascript
const updateItemList = (event, index) => { const newList = [...itemList]; newList[index].checked = event.target.checked; setItemList(newList); };
javascript
const 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.

javascript
const updateFeaturesList = (event, index) => { const newFeatures = features.map((feature, idx) => { if (idx === index) { return { ...feature, checked: event.target.checked }; } return { ...feature }; }); setListFeatures(newFeatures); };
javascript
const 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.

javascript
const updateItemList = (event, index) => { const newList = structuredClone(itemList); newList[index].checked = event.target.checked; setItemList(newList); };
javascript
const 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:

javascript
import { useState } from "react"; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); console.log(count); // 0 }; }
javascript
import { 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.

javascript
increment = () => { this.setState({ count: this.state.count + 1 }, () => { console.log(this.state.count); // Updated state value }); };
javascript
increment = () => { 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.

javascript
import { 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); }; }
javascript
import { 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.

javascript
import { useState } from "react"; function Counter() { const [count, setCount] = useState(0); const increment = () => { setCount(count + 1); console.log(count + 1); }; }
javascript
import { 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:

javascript
const [count, setCount] = useState(0); const incrementTwice = () => { setCount(count + 1); setCount(count + 1); };
javascript
const [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.

javascript
const [count, setCount] = useState(0); const increment = () => { setCount((c) => c + 1); setCount((c) => c + 1); };
javascript
const [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:

javascript
function FeatureList() { const defaultFeatures = ["feature1", "feature2"]; const isDefault = useCallback( (feature) => defaultFeatures.includes(feature), [defaultFeatures], ); }
javascript
function 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.

javascript
const defaultFeatures = useMemo(() => ["feature1", "feature2"], []);
javascript
const 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.

javascript
const defaultFeatures = ["feature1", "feature2"]; function FeatureList() { const isDefault = useCallback( (feature) => defaultFeatures.includes(feature), [], ); }
javascript
const 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.

jsx
function 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> ); }
jsx
function 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:

jsx
const onInputChange = (name) => (event) => { setFormData({ ...formData, [name]: event.target.value }); }; // ... <input type="text" name="firstName" value={formData.firstName} onChange={onInputChange("firstName")} />;
jsx
const 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.

jsx
function 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> ); }
jsx
function 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