author-avatar-imageGorkem Arpaci
11 Mar, 2024β€’12m read time

How to remove unnecessary useEffects

author-avatar-image

Effects serve as an escape hatch from the typical React world, allowing you to sync your components with external systems such as non-React widgets, networks, or the browser DOM. If your component doesn't interact with an external system but simply updates its state based on props or state changes, using an Effect may be unnecessary. By eliminating unnecessary Effects, your code becomes clearer, more efficient, and less prone to errors.

How to remove unnecessary Effects

There are two scenarios where Effects are unnecessary:

  1. Effects are not needed for data manipulation before rendering. For instance, if you intend to filter a list before displaying it, using an Effect to update a state variable upon list changes might seem intuitive. However, this approach is redundant. When you modify the state, React follows a sequence: it invokes your component functions to determine what to render, then commits these changes to the DOM, updating the display. Afterwards, React executes Effects. If an Effect immediately modifies the state again, the entire process restarts. If an Effect promptly modifies the state again, the render cycle start from scratch! To prevent redundant rendering cycles, ensure that all data transformation occurs at the highest level of your components. This ensures that the code automatically re-executes whenever there are changes in props or state.
  2. Try to handle user events in callbacks, not in Effects. Because in event handlers you have the full control, for example you know the which button is clicked. But if you pass the control to Effects, by the time an Effect runs, you don’t know what the user did, for example which button is clicked.

To help you gain the right intuition, let’s look at some common concrete examples!

Updating state based on either props or state

Let's assume a component with two state variables: firstName and lastName. Goal is to calculate a fullName from them by combining them. Additionally, let's update fullName whenever firstName or lastName change. Your first approach might be to add a fullName state variable and update it in an Effect:

component.js

const Component = () => {
  const [firstName, setFirstName] = useState('Tate');
  const [lastName, setLastName] = useState('McRae');

// πŸ”΄ Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ... }

This approach is unnecessarily complex and inefficient. It triggers a complete render pass with an outdated value for fullName, followed immediately by another render with the updated value. To fix the process, remove both the state variable and the Effect.

component.js

const Component = () => {
  const [firstName, setFirstName] = useState('Tate');
  const [lastName, setLastName] = useState('McRae');
// βœ… Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ... }

If something can be computed from the existing props or state, it's best not to store it in the component's state. Instead, perform the calculation during the rendering process. This approach enhances code efficiency by eliminating additional "cascading" updates, simplifies code by reducing its number of lines(yay good developer experience!), and minimizes the errors caused by inconsistencies between different state variables. If this approach feels new to you, welcome to thinking like React.

Caching expensive calculations

This component computes visibleTodos by filtering the todos received via props based on the filter prop. It seems appealing to store this result in state and update it using an Effect:

todoList.js

const TodoList = ({ todos, filter }) => {
  // πŸ”΄ Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}

Similar to the previous example, this approach is both unnecessary and inefficient. Let's begin refactoring by removing both the state and the Effect:

todoList.js

const TodoList = ({ todos, filter }) => {
// βœ… This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ... }

Looks fine, this code is acceptable. However, if getFilteredTodos() is slow or if there are numerous todos, best practice is not to recalculate getFilteredTodos() whenever an unrelated state variable, such as newTodo, changes.

You can cache and store expensive calculations with useMemo hook.

todoList.js

import { useMemo, useState } from 'react';

const TodoList = ({ todos, filter }) => {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// βœ… Does not re-run unless todos or filter change
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ... }

For one-liner lovers:

todoList.js

import { useMemo, useState } from 'react';

const TodoList = ({ todos, filter }) => {
  const [newTodo, setNewTodo] = useState('');
  // βœ… Does not re-run getFilteredTodos() unless todos or filter change
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ... }

This informs React that the inner function should only be rerun if either todos or filter has changed. React will remember the return value of getFilteredTodos() during the initial render. In subsequent renders, React will check if todos or filter have changed. If they remain the same as before, useMemo will return the previously stored result. However, if there are changes, React will call the inner function again and store its new result.

Resetting all state when a prop changes

Below Profile component, which receives a userId prop, there is an input field for comments that utilizes a comment state variable to store its value. However, a problem arises when navigating from one profile to another: the comment state does not reset, potentially leading to accidentally posting a comment on the wrong user's profile. To address this issue, you aim to clear the comment state variable whenever the userId changes.

profile.js

const Profile = ({ userId }) => {
  const [comment, setComment] = useState('');

// πŸ”΄ Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
// ... }


This approach is inefficient because Profile and its children will render initially with stale values, followed by another render which caused useEffect. It's also complex because you'd need to implement this logic in every component that contains state within Profile. For instance, if the comment UI is nested, you'd need to clear out nested comment states as well.

A better alternative is to inform React that each user's profile is unique by assigning it an explicit key. Divide your component into two parts and pass a key attribute from the outer component to the inner one:

profile.js

const Profile = ({ userId }) => 
  return (
    <Page
      userId={userId}
key={userId}
/> ); } export default Profile;

page.js


const Page = ({ userId }) => {
// βœ… This and any other state below will reset on key change automatically
const [comment, setComment] = useState('');
// ... }


Typically, React maintains the state when the same component is rendered in the same position. However, by passing userId as a key to the Page component, you're instructing React to treat two Page components with different userId values as distinct components that shouldn't share any state. Whenever the key (in this case, userId) changes, React will recreate the DOM and reset the state of the Page component along with all its children. Consequently, the comment field will automatically clear out when navigating between profiles.

It's worth noting that in this example, only the outer Profile component is exported and visible to other files in the project. Components rendering Profile don't need to pass the key to it; instead, they pass userId as a regular prop. The fact that Profile passes it as a key to the inner Page component is an implementation detail.

Modifying some state when a prop changes

Let's assume you may need to reset or modify a portion of the state when a prop changes, but not the entire state.

Consider the List component, which accepts a list of items as a prop and stores the selected item in the selection state variable. If you wish to reset the selection to null whenever the items prop receives a different array:

list.js

const List = ({ items }) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

// πŸ”΄ Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ... }


This approach also far from ideal. Upon every change in the items prop, both the List component and its child components will render initially with a stale selection value. Subsequently, React will update the DOM and execute the Effects. Finally, invoking setSelection(null) will trigger another re-render of the List and its child components, initiating this entire cycle anew.

To fix this, let's begin by removing the Effect. Instead, directly modify the state during rendering:

list.js

const List = ({ items }) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ... }

It can be hard to understand the way of storing information from previous renders like above, but it’s better than updating the same state in an Effect. In the above example, setSelection is called directly during a render. React will re-render the List immediately after it exits with a return statement. React has not rendered the List children or updated the DOM yet, so this lets the List children skip rendering the stale selection value.

Let's make it even better. Modifying state based on props or other state can complicate your data flow, making it harder to understand and debug.
Instead of storing and resetting the selected item directly, you can store the ID of the selected item:

list.js

const List = ({ items }) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
// βœ… Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
// ... }

Now there is no need to β€œmodify” the state at all. If the item with the selected ID is in the list, it remains selected. If it’s not, the selection calculated during rendering will be null because no matching was found. This behavior is different, but considerably better since most changes to items preserve the selection.

Sharing logic between event handlers

Let's consider a scenario, where a product page features two buttons (Buy and Checkout) allowing users to purchase the product, displaying a notification upon adding the product to the cart seems necessary. However, directly calling showNotification() within both buttons' click handlers can feel redundant. As a result, there might be a temptation to centralize this logic within an Effect:

productPage.js

const ProductPage = ({ product, addToCart }) => {
  // πŸ”΄ Avoid: Event-specific logic inside an Effect
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  const handleBuyClick = () => addToCart(product);
  

  const handleCheckoutClick = () => {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}


This Effect is unnecessary and likely to lead to bugs. For instance, if your app retains the shopping cart information between page reloads, adding a product once and then refreshing the page will cause the notification to reappear. It will continue to reappear upon every subsequent refresh of that product page because product.isInCart will already be true upon page load, triggering the Effect to call showNotification().

If you unsure whether certain code belongs in an Effect or an event handler, consider why the code needs to execute. Reserve Effects only for code that should run due to the component being displayed to the user. In this case, the notification should only appear in response to a user pressing the button, not because the page was displayed! Therefore, it's best to remove the Effect and refactor the shared logic into a function called from both event handlers:

productPage.js

const ProductPage = ({ product, addToCart }) => {
// βœ… Good: Event-specific logic is called from event handlers
const buyProduct = () => {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
const handleBuyClick = () => buyProduct();
const handleCheckoutClick = () => {
buyProduct();
navigateTo('/checkout'); } // ... }

This approach not only eliminates the unnecessary Effect but also resolves the bug.

Sending a POST request

The Form component initiates two types of POST requests: one for sending an analytics event upon mounting, and another when the form is filled and the Submit button is clicked, triggering a POST request to the /api/register endpoint.

form.js

const Form = () => {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

// βœ… Good: This logic should run because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// πŸ”΄ Avoid: Event-specific logic inside an Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) { e.preventDefault(); setJsonToSubmit({ firstName, lastName }); } // ... }


Let's apply the same criteria as in the previous example.

The analytics POST request should indeed remain within an Effect. Its purpose is to send the analytics event because the form was displayed. (Note that it might fire twice in development, but see here for how to handle it.)

However, the /api/register POST request is not triggered by the form being displayed. Its execution should be tied to a specific moment: when the user presses the button. It should only occur during that particular interaction. Therefore, it's best to remove the second Effect and refactor the POST request to the event handler:

form.js

const Form = () => {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // βœ… Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  const handleSubmit = (e) => {
    e.preventDefault();
// βœ… Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName });
}
// ... }


When deciding whether to place logic in an event handler or an Effect, the key question to address is the nature of the logic from the user's perspective. If the logic is a direct result of a specific user interaction, it should be retained in the event handler. However, if the logic is triggered by the user's view of the component on the screen, it belongs in the Effect.

Passing data to the parent

This Child component first fetches some data and then passes it to the Parent component in an Effect:

parent.js

const Parent = () => {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

child.js

const Child = ({ onFetched }) => {
  const data = useSomeAPI();
// πŸ”΄ Avoid: Passing data to the parent in an Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ... }

In React, data typically flows from parent components to their children. When troubleshooting issues on the screen, you can trace the source of information by navigating up the component hierarchy until you identify the component passing the incorrect prop or maintaining the wrong state. When child components modify the state of their parent components within Effects, it complicates the data flow tracing process. Since both the child and the parent require the same data, it's preferable to have the parent component fetch the data and pass it down to the child component instead:

parent.js

function Parent() {
  const data = useSomeAPI();
  // ...
  // βœ… Good: Passing data down to the child
return <Child data={data} />;
}

This approach simplifies matters and maintains a predictable data flow: the data streams down from the parent to the child component.

Conclusion

  • Do not use Effect if you can calculate something during render.
  • Replace useEffect with useMemo to cache expensive calculations.
  • Pass unique keys to reset the state of an entire component tree.
  • To reset a particular state value in response to a prop change, set it during rendering.
  • Code that runs because a component was displayed should be lived in Effects, use event handlers for the rest.
  • Consider lifting state up when synchronizing state variables across different components.

Sources:

React.dev

Thank you for reading this far. See you in the next chapter :)

Comments
Be the first to commentNobody's responded to this post yet.Add your thoughts and get the conversation going.