Use DOM and CSS in Place of React Stateful Variables for Performance-Sensitive UI

Background: Stacked header with changing height

I've recently encountered this UI problem when building a feature in a page.

The page has a header already in place. The header has a collapse state listening for users' scrolling behavior. When the page scrolls down past certain position, the header will collapse and get a shorter height. But as soon as the user reverts the direction and scrolls back up, the header will uncollapse and get its taller height back.

I'm building a bottom section in this page. The section contains a navigation bar in its own that shall be sticky to the top of the page, but below the page's header, when it is scrolled past. In other words, my navigation bar needs to stacked with the page header, and pick up a top distance that should equal to the page header's height.

React-heavy solution

The existing header component already listens to the users' scroll event, and is setting collapsed state internally. Initially, I was thinking of this most common approach:

  • hoist up the collapsed state in a shared place, in this case at page level
  • both components (header and the stacked tab) read from the shared state

This approach plays with React's component lifecycle. A conditional check is called onScroll, which triggers very frequently. And it often calls setState too, when the collapse state changes.

In practice, the issue is observable when the page is already heavy. It doesn't feel so smooth when loading and scrolling are happening at the same time.

DOM-centric solution

I realize that the end result of the above state computation and hoisting is to eventually write a class name, .collapsed and the lack thereof. So, instead of using a React state, I decided to write the class name to DOM directly, at the onScroll event:

function useCollapsibleHeader(headerRef: React.RefObject<HTMLElement>) {
  useEffect(() => {
    let lastScroll: number | undefined = 0;
    function checkScrollDirection() {
      requestAnimationFrame(() => {
        const currentScroll = window.scrollY;

        if (lastScroll == null) {
          lastScroll = currentScroll;
          return;
        }

        const currentHeader = headerRef.current;
        if (!currentHeader) {
          return;
        }

        const scrollDelta = Math.abs(currentScroll - lastScroll);
        if (scrollDelta > SCROLL_THREADHOLD) {
          if (
            currentScroll > lastScroll &&
            currentScroll > ANIMATION_PADDING_TOP
          ) {
            currentHeader.classList.add('collapsed');
          } else {
            currentHeader.classList.remove('collapsed');
          }
          lastScroll = currentScroll;
        }
      });
    }
    window.addEventListener(
      'scroll',
      checkScrollDirection,
      supportsPassiveEventHandler
        ? {
            passive: true,
          }
        : undefined
    );

    return () => {
      window.removeEventListener('scroll', checkScrollDirection);
    };
  }, [headerRef]);
}

And then, both components can use the .collapsed selector for styling.

// page header has two different heights
:global(.collapsed) .pageHeader {
  height: $collapsedNavBarHeight;
}
.pageHeader {
  height: $normalNavBarHeight;
}
// sticky secondary nav takes respective heights as top value for sticky
:global(.collapsed) .secondaryNav {
  top: $collapsedNavBarHeight;
}
:global(.collapsed) .secondaryNav {
  position: sticky;
  top: $normalNavBarHeight;
}

The result is the seamless animation shown in the video.

In summary,

The usage applies to performance-sensitive situation that involves:

  • two separate components that are closely related share one UI (animation)
  • involves DOM measurement or calculation in performance-sensitive situations (onScroll, mutation or intersection observer)

We can save the state in this case, and use CSS instead

  • instead of setState in the DOM API call, directly write the class name to the DOM instead
    i.e., classList.add(), classList.remove() on a parent node
  • in the relevant components, instead of reading from React states, use CSS selectors to select the affected