Handling Side Effects in React: Best Practices and Custom Hooks

Introduction

React is designed to be declarative, making UI updates predictable based on state and props. However, real-world applications often need to perform side effects, such as fetching data, subscribing to events, updating the DOM, or interacting with APIs. Handling these side effects efficiently is crucial for maintaining performance and preventing issues like memory leaks.

Understanding Side Effects in React

A side effect is any operation that interacts with the outside world or modifies something outside the function’s scope. Common side effects include:

  • Fetching data from an API
  • Updating the document title
  • Managing timers (setTimeout, setInterval)
  • Listening to browser events (e.g., window resize, key press)
  • Interacting with local storage or cookies

Unlike state updates, side effects should be handled separately to ensure that they don’t disrupt React’s rendering process.

Using useEffect to Handle Side Effects

React provides the useEffect hook for managing side effects in functional components. You can run effects after rendering and optionally clean them up to prevent memory leaks.

Custom hooks help separate concerns and promote better code structure.
They make your logic easier to test and maintain across different parts of your application. This approach also improves readability and reduces code duplication.

const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let isMounted = true; // To prevent state updates on unmounted component
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const result = await response.json();
        if (isMounted) setData(result);
      } catch (error) {
        console.error("Error fetching data:", error);
      } finally {
        if (isMounted) setLoading(false);
      }
    };

    fetchData();

   

 return () => {
      isMounted = false; // Cleanup to avoid memory leaks
    };
  }, [url]);

  return { data, loading };
};

“Creating a Custom Hook for Fetching Data”

  • useFetch is a custom hook that accepts a URL and returns the fetched data and loading status.
  • The useEffect hook performs the side effect of fetching data after the component mounts.
  • A flag isMounted prevents updates to state if the component is unmounted before the fetch completes, avoiding memory leaks.
  • The function is wrapped in a try/catch block for error handling.
  • The returned object { data, loading } is ready for use in any component.
const Users = () => {
    const { data, loading } = useFetch("https://jsonplaceholder.typicode.com/users");
  
    if (loading) return <p>Loading...</p>;
  
    return (
      <ul>
        {data.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  };
  • The Users component uses the useFetch hook with a public API URL.
  • While loading, it displays a loading message.
  • After the data loads, it maps through the user list and renders it.
  • The data fetching logic is completely abstracted, making the component more readable and focused on rendering.

Why Use a Custom Hook?

  • Reusability: Use the hook across multiple components.
  • Cleaner Code: Separates logic from UI.
  • Improved Readability: Keeps components focused on rendering.

Common Mistakes to Avoid

  • Forgetting the Dependency Array

Add an empty dependency array [ ] to run it only on mount.

useEffect(() => {
    fetchData();
}, []);

By passing an empty dependency array [], the effect runs only once on mount—like componentDidMount. This is useful for initialization logic such as API calls.

  • Not Cleaning Up Side Effects

Remove the event listener in the cleanup function.

useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
}, []);

Always clean up listeners, timers, and subscriptions in the return function of useEffect. This ensures that when the component unmounts, no unnecessary operations remain.

  • Calling an Async Function Directly in useEffect

Use an inner async function.

useEffect(() => {
    const fetchData = async () => {
        const data = await fetchData();
        setData(data);
    };
    fetchData();
}, []);

You should define an async function inside the useEffect and call it. The outer useEffect itself should remain synchronous to allow React to manage cleanup and scheduling properly.

Conclusion

Handling side effects efficiently is key to building scalable and maintainable React applications. By following best practices with useEffect and leveraging custom hooks, you can simplify your components, optimize performance, and avoid common pitfalls.