An illustration with the text: Effectomania Effectomania

Leaner React - Part 3 - useEffect


React effects are a powerful tool. They allow you to execute a function when some dependencies change, or when the component mounts/unmounts. Originally, they are meant to synchronize a component with an external system. In my opinion, they are most often misused and complicating everything.

Disclaimer: these are not hard rules, but rather recommendations that usually lead to cleaner code. There are exceptions that justify not following them.

TLDR:

  • Avoid useEffect for handling value changes, prefer normal functions.
  • If you can’t avoid an effect, extract it into a named hook.

Avoid useEffect for handling value changes, prefer normal functions

Most often, I see useEffect being used to handle chains of computations: when this value changes, that should happen or when this component mounts, do that. This mental model is straight forward, easy to remember and simple to use.

In my opinion, using this approach swiftly becomes problematic. I’ve worked with large applications that had hundreds of effects that would trigger eachother, making them feel non-deterministic. Keeping track of what happened where was insanely difficult. Try to remove an useEffect because it looks unnecessary? Watch the whole app collapse. On top of that, this approach made the applications slow down a lot: if a value change triggers a re-rendering of a component, after which an effect is executed, which might trigger another re-rendering of another (or the same) component and so on and so forth is just highly inefficient.

Effect with chain of computation
import { useEffect, useState } from "react";

export function Counter() {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    if (counter > 10) {
      setCounter(0);
    }
  }, [counter]);

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={() => setCounter(counter + 1)}>Increment</button>
    </div>
  );
}

Avoid using useEffect for handling chains of computations. useEffect is meant to synchronize rendering with external systems. External here means external to React. It should be used very sparesly. Prefer normal function calls.

Normal functions
import { useState } from "react";

export function Counter() {
  const [counter, setCounter] = useState(0);

  const incrementOrReset = () => {
    if (counter > 10) {
      setCounter(0);
      return;
    }

    setCounter(counter + 1);
  };

  return (
    <div>
      <h1>Counter: {counter}</h1>
      <button onClick={incrementOrReset}>Increment</button>
    </div>
  );
}

There is a whole article on You Might Not Need an Effect on the React website for more details.

If you can’t avoid an effect, extract it into a named hook

When there is a valid reason to use an effect, try to extract the logic into a named hook. This helps immensely with readability and maintainability:

Plain useEffect
import { useState, useEffect, useRef } from "react";

export function LazyComponent() {
  const [isVisible, setIsVisible] = useState(false);
  const elementRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { threshold: 0.5 }
    );

    if (elementRef.current) {
      observer.observe(elementRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={elementRef} style={{ minHeight: "100vh", padding: "20px" }}>
      {isVisible ? (
        <h2>🎉 I am now visible! 🎉</h2>
      ) : (
        <h2>Scroll down to reveal me 👇</h2>
      )}
    </div>
  );
}

Named Hook
import { useState, useEffect, useRef, RefObject } from "react";

export function LazyComponent() {
  const elementRef = useRef<HTMLDivElement>(null);

  const isVisible = useIsVisible(elementRef);

  return (
    <div ref={elementRef} style={{ minHeight: "100vh", padding: "20px" }}>
      {isVisible ? (
        <h2>🎉 I am now visible! 🎉</h2>
        ) : (
        <h2>Scroll down to reveal me 👇</h2>
      )}
    </div>
  );
}

function useIsVisible(ref: RefObject<HTMLDivElement>) {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (!ref.current) {
      return () => {};
    }

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) {
          return;
        }

        setIsVisible(true);
        observer.disconnect();
      },
      { threshold: 0.5 },
  );

  observer.observe(ref.current);

  return () => observer.disconnect();
  }, []);

  return isVisible;
}

Conclusion

That’s it all for this series! Applying these recommendations will significantly improve the readability of your React code.