DEV Community

Ayako yk
Ayako yk

Posted on

When Not to Use useEffect in React

In the last blog post, I discussed the foundation of useEffect. It is used to synchronize the React component with external systems. However, it's essential to understand that synchronization via useEffect should only occur after side effects have been executed. Unnecessary use of useEffect can lead to slow performance and potential errors. Below are some cases and examples where the unnecessary use of useEffect should be avoided.

The content and examples are taken from the React documentation.

Updating State Based on Props or State
React re-renders whenever there's a state change, so using useEffect to update the state based on a change in props or state is often unnecessary.

Here's an example from the React Documentation, where fullName should be updated whenever firstName and lastName change:

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

  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
    }, [firstName, lastName]);
  ...
}
Enter fullscreen mode Exit fullscreen mode

Instead, fullName can be calculated during rendering:

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

  const fullName = firstName + ' ' + lastName;
  ...
}
Enter fullscreen mode Exit fullscreen mode

Caching Expensive Calculations
When calculations are expensive --- meaning they take a significant amount of time to compute --- caching and avoiding unnecessary re-renders are recommended. We can achieve this by using useMemo.

Here's an example where we filter a to-do list based on the passed props. By using useMemo, the calculation will only be re-rendered if the props change.

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  ...
}
Enter fullscreen mode Exit fullscreen mode

Resetting All State When a Prop Changes
When we have a state, such as comments for each user, and we need to reset the state every time the userId changes (because we don't want to display the comment for other users), we might be tempted to use useEffect. However, the code below is incorrect:

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  useEffect(() => {
    setComment('');
    }, [userId]);
    ...
}
Enter fullscreen mode Exit fullscreen mode

The issue is that React first renders the comment, and then the useEffect runs. This means the comment will be displayed before it's reset.
We can fix this by passing the userId as a key in the parent component:

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  const [comment, setComment] = useState('');
  ...
}
Enter fullscreen mode Exit fullscreen mode

Passing a key tells React to treat each Profilecomponent as independent, ensuring it resets properly when the userId changes.

Adjusting Some State When a Prop Changes
When we want to reset or adjust a part of the state when a prop changes, but not all of it, we might be tempted to use useEffect.

Here's an example from the React documentation. When the items prop changes, the selection should be reset, while isReverse should remain the same:

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

  useEffect(() => {
    setSelection(null);
    }, [items]);
    ...
}
Enter fullscreen mode Exit fullscreen mode

With this code, React first renders the selection, and then it is set to null.

One solution is to create an additional state to store the previous value:

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

  const [prevItems, setPrevItems] = useState(items);
    if (items !== prevItems) {
      setPrevItems(items);
      setSelection(null);
    }
  ...
}
Enter fullscreen mode Exit fullscreen mode

The React documentation mentions that this approach is better than using useEffect, but there's an even more efficient way:
The following code calculates the state directly during rendering.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);

  const selection = items.find(item => item.id === selectedId) ?? null;
  ...
}
Enter fullscreen mode Exit fullscreen mode

Sharing Logic Between Event Handlers
This code from the React documentation shows how to display a notification when a user buys a product by clicking either a "buy" button or a "checkout" button:

function ProductPage({ product, addToCart }) {
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

In this code, product is declared in the dependency array, so every time the user visits or refreshes the page, the notification is displayed. This is incorrect. The notification should only be shown as a result of a user action (like clicking a button). Therefore, it should be triggered inside an event handler, not in the useEffect.

Here's how you can fix it:

function ProductPage({ product, addToCart }) {
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Sending a POST Request
How should we handle these two kinds of POST requests?
One is to send an analytics event when the page mounts, and the other is to send a form submission when a submit button is clicked.

Here's an example from the React documentation:

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

// Correct
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

// Incorrect
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

In the first case, useEffect is appropriate because the POST request should be sent when the page is displayed or mounted. In the second case, however, the POST request should be sent only when the user clicks the submit button. Therefore, it should be handled inside an event handler, not useEffect.

Here's a correct version:

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

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    post('/api/register', { firstName, lastName });
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Chains of Computations
The code below uses multiple useEffect hooks, which trigger another useEffect when one state changes:

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }
...
Enter fullscreen mode Exit fullscreen mode

This approach is not only inefficient but also prone to errors as the logic evolves (e.g., when tracking past state changes).
It's better to calculate and adjust the state within the event handler, rather than using useEffect.

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
    throw Error('Game already ended.');
  }

  setCard(nextCard);
  if (nextCard.gold) {
    if (goldCardCount <= 3) {
      setGoldCardCount(goldCardCount + 1);
    } else {
      setGoldCardCount(0);
      setRound(round + 1);
      if (round === 5) {
        alert('Good game!');
      }
    }
  }
} 
...
Enter fullscreen mode Exit fullscreen mode

In some cases, such as when showing a dropdown menu, we might still need a chain of useEffect hooks. However, we should first consider whether it can be calculated directly in the event handler instead of relying on multiple useEffect hooks.

Initializing the Application
When you have logic that should run only once when an app is initially loaded, you might be tempted to include a useEffect hook at the top level in App.js.

function App() {
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  ...
}
Enter fullscreen mode Exit fullscreen mode

However, React runs twice in Strict Mode, which can lead to unexpected behavior. Even though the code runs only once in production, it's still best to avoid this pattern.

One solution is to declare a variable that tracks whether the initialization has already occurred:

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  ...
}
Enter fullscreen mode Exit fullscreen mode

Notifying Parents of State Changes
If you have a toggle that is triggered by either clicking or dragging, you might want to use the useEffect hook to change the state when the props change. However, this approach is incorrect because the state is rendered first, and then useEffect is run.

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Rather than using useEffect, we should have an updating function and include it in both handlers:

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }
...
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can lift the state up and let the parent component control the state:

function Toggle({ isOn, onChange }) {
  function handleClick() {
  onChange(!isOn);
}

function handleDragEnd(e) {
  if (isCloserToRightEdge(e)) {
    onChange(true);
  } else {
    onChange(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

Passing Data to the Parent
In this example, the child component is trying to fetch data and pass it to its parent component.

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

function Child({ onFetched }) {
  const data = useSomeAPI();

  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
...
}
Enter fullscreen mode Exit fullscreen mode

However, this approach goes against React's general pattern of data flow. In React, data typically flows downward from parent to child components, not the other way around. Instead, the parent should pass the data down to its child components.

Subscribing to an External Store
In some cases, you might need to subscribe to data outside of React's state, such as data from a third-party library or a built-in API. Since this data can change without React's knowledge, you need to manually subscribe to it. This is typically done using the useEffect hook.

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
...
}
Enter fullscreen mode Exit fullscreen mode

This is a common pattern for subscribing to external data, but React provides a purpose-built hook for subscribing to external stores: useSyncExternalStore. This approach is less error-prone than manually subscribing to mutable data in useEffect.

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  ...
}
Enter fullscreen mode Exit fullscreen mode

Fetching Data
The SearchResult component fetches data when the query or page prop changes. This logic should be placed inside useEffect, not an event handler.

While the user types a search query, there's a risk of a race condition. This happens because each character typed triggers a new fetch request, and responses might come back in an unexpected order. For example, if the user types quickly, the requests may arrive out of order, leading to incorrect results being displayed. This is called a "race condition":

two different requests 'raced' against each other and came in a different order than you expected.

To fix this issue, a cleanup function is needed to ignore outdated responses. This ensures that only the results of the last requested fetch are displayed.

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
      setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

With this cleanup function in place, only the latest search query or the last fetch request will update the state, ensuring the user sees the correct results.

In the previous blog post, we explored the fundamental uses of useEffect, and it's easy to feel tempted to use it whenever it seems applicable. However, it's important to fully understand why and when the useEffect hook is necessary, and to use it correctly.

Top comments (0)