DEV Community

Hamed Fatehi
Hamed Fatehi

Posted on • Updated on

When Direct DOM Manipulation Outperforms React State Management

Introduction:

Optimizing performance is a critical aspect of developing web applications with React. To demonstrate various techniques for improving React performance, we'll use a scroll progress bar as a real-world example. In this article, we'll explore three different approaches to implementing a scroll progress bar. We start with the worst and move are way up to the most performant approach.

1.The Ugly Way:

In this initial approach, we place the scroll logic directly in the parent component and use useState. Although this may seem straightforward, it can lead to unnecessary re-renders of the parent component and its children.

// ParentComponent.js
function ParentComponent() {
  const [scrollProgress, setScrollProgress] = useState(0);

  const handleScroll = () => {
    const scrollTop = document.documentElement.scrollTop;
    const windowHeight = window.innerHeight;
    const docHeight = document.documentElement.scrollHeight - windowHeight;
    const scrollPercentage = (scrollTop / docHeight) * 100;
    setScrollProgress(scrollPercentage);
  };

  useEffect(() => {
    console.log("Parent component rendered");
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div>
      <h1>Parent Component</h1>
      <ProgressBar progress={scrollProgress} />
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.log("Child component rendered");
  return <p>Child Component</p>;
}

Enter fullscreen mode Exit fullscreen mode

In this example, every time the user scrolls, the parent component and its children are re-rendered.

2. The Bad Way:

A better approach is to encapsulate the scroll logic and state within a separate ScrollProgressBar component. By doing so, we isolate the re-rendering of the progress bar from the rest of the application.

// ScrollProgressBar.js
function ScrollProgressBar() {
  const [scrollProgress, setScrollProgress] = useState(0);

  const handleScroll = () => {
  // ... same as the previous example
  };

  useEffect(() => {
    console.log("ScrollProgressBar rendered");
    // ... same as the previous example
  }, []);

  return <ProgressBar progress={scrollProgress} />;
}

// ParentComponent.js
function ParentComponent() {
  console.log("Parent component rendered");
  return (
    <div>
      <h1>Parent Component</h1>
      <ScrollProgressBar />
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.log("Child component rendered");
  return <p>Child Component</p>;
}

Enter fullscreen mode Exit fullscreen mode

Although this method eliminates the unnecessary re-renderings of unrelated components (specially when they are not memoized), still each scroll event triggers a re-render of the ScrollProgressBar component itself, which could be avoided. See the last part to find out how:

3. The Good Way:

The most performant approach is to directly manipulate the DOM without using useState. This eliminates unnecessary re-rendering altogether.

// ScrollProgressBar.js
function ScrollProgressBar({ color }) {
  const progressRef = useRef(null);   

  useEffect(() => {
    console.log("ScrollProgressBar mounted");

     if (!progressBarRef.current) {
        return null;
     } 
     const handleScroll = () => {
        const scrollTop = document.documentElement.scrollTop;
        const windowHeight = window.innerHeight;
        const docHeight = document.documentElement.scrollHeight - windowHeight;
        const scrollPercentage = (scrollTop / docHeight) * 100;
        progressRef.current.style.width = `${scrollPercentage}%`;
     };

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

  return (
    <div
      ref={progressRef}
      style={{ width: "0%", height: "5px", backgroundColor: color }}
    />
  );
}

// ParentComponent.js
function ParentComponent() {
  console.log("Parent component rendered");
  return (
    <div>
      <h1>Parent Component</h1>
      <ScrollProgressBar color="red" />
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.log("Child component rendered");
  return <p>Child Component</p>;
}

Enter fullscreen mode Exit fullscreen mode

In this final example, the ScrollProgressBar component's DOM is manipulated directly, avoiding unnecessary re-renders. The console.log statements show that the parent and child components are not re-rendered during scrolling, ensuring optimal performance.

Conclusion:

It's essential to keep in mind that while React's state management is convenient and powerful, there are situations where direct DOM manipulation can lead to better performance, especially when dealing with frequently updating elements and heavy parent components with complex logic. Always consider the trade-offs between convenience and performance when deciding on an approach to optimize your application.

Top comments (0)