Back

Building Scroll-Aware Components in React

Building Scroll-Aware Components in React

Your scroll handler fires 60 times per second. Your component re-renders on every tick. Your users see jank instead of smooth animations. This pattern breaks React applications faster than almost any other performance mistake.

Building scroll-aware components in React requires understanding when to avoid React’s state system entirely. This article covers the right tools for React scroll position tracking, why Intersection Observer in React should be your default choice, and how to implement performant scroll handling when you genuinely need raw scroll data.

Key Takeaways

  • Intersection Observer handles most scroll-aware patterns without performance cost
  • When you need continuous scroll values, use refs and requestAnimationFrame instead of state
  • Reserve useSyncExternalStore for shared scroll state across components
  • Test with framework routing and always provide reduced-motion alternatives

Why Intersection Observer Should Be Your First Choice

Most scroll-driven UI React patterns don’t actually need scroll position. They need visibility detection: Is this element on screen? Has the user scrolled past this section?

Intersection Observer handles these cases without running application code on every scroll event. The browser optimizes observation internally, making it dramatically more performant than scroll listeners.

import { useInView } from 'react-intersection-observer';

function FadeInSection({ children }) {
  const { ref, inView } = useInView({
    threshold: 0.1,
    triggerOnce: true,
  });

  return (
    <div ref={ref} className={inView ? 'visible' : 'hidden'}>
      {children}
    </div>
  );
}

The react-intersection-observer library wraps this API cleanly. Use it for section tracking, lazy loading, and reveal animations.

When You Actually Need Scroll Position

Some patterns require continuous scroll values: parallax effects, progress indicators, or scroll-linked transforms. Here’s where most tutorials fail you.

Never do this:

// This causes re-renders on every scroll tick
const [scrollY, setScrollY] = useState(0);

useEffect(() => {
  const handleScroll = () => setScrollY(window.scrollY);
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

This pattern triggers React’s reconciliation process 60+ times per second during active scrolling.

The Ref-Based Pattern

Store scroll values in refs and update the DOM directly:

import { useRef, useEffect } from 'react';

function ParallaxElement() {
  const elementRef = useRef(null);
  const rafId = useRef(null);

  useEffect(() => {
    const handleScroll = () => {
      if (rafId.current) return;
      
      rafId.current = requestAnimationFrame(() => {
        if (elementRef.current) {
          const offset = window.scrollY * 0.5;
          elementRef.current.style.transform = `translateY(${offset}px)`;
        }
        rafId.current = null;
      });
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => {
      window.removeEventListener('scroll', handleScroll);
      if (rafId.current) cancelAnimationFrame(rafId.current);
    };
  }, []);

  return <div ref={elementRef}>Parallax content</div>;
}

Key details: requestAnimationFrame throttles updates to the display refresh rate. The { passive: true } option tells the browser you won’t call preventDefault(), enabling scroll optimizations.

useSyncExternalStore for Shared Scroll State

When multiple components need scroll position, treat it as an external store:

import { useSyncExternalStore } from 'react';

const scrollStore = {
  subscribe: (callback) => {
    window.addEventListener('scroll', callback, { passive: true });
    return () => window.removeEventListener('scroll', callback);
  },
  getSnapshot: () => window.scrollY,
  getServerSnapshot: () => 0,
};

function useScrollPosition() {
  return useSyncExternalStore(
    scrollStore.subscribe,
    scrollStore.getSnapshot,
    scrollStore.getServerSnapshot
  );
}

This pattern works correctly with React’s concurrent features and handles server-side rendering. In practice, you should still throttle or coarse-grain updates (for example by emitting section changes instead of raw pixels) to avoid re-rendering on every scroll frame.

useLayoutEffect vs useEffect for Scroll

Use useLayoutEffect when you need to measure DOM elements before the browser paints. This prevents visual flicker when calculating positions with getBoundingClientRect().

Use useEffect for attaching scroll listeners. The listener setup doesn’t need to block painting.

Framework Considerations

Next.js App Router and similar frameworks manage scroll restoration automatically. Custom scroll handlers can conflict with this behavior. Test scroll-aware components after client-side navigation to catch restoration issues.

CSS Scroll-Driven Animations

Modern browsers support animation-timeline: scroll() for pure CSS scroll effects. Browser support remains incomplete, but this approach eliminates JavaScript entirely for supported cases. Use it with progressive enhancement—provide a fallback experience for unsupported browsers.

Accessibility

Always respect prefers-reduced-motion. Wrap scroll-triggered animations:

const prefersReducedMotion =
  typeof window !== 'undefined' &&
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

Skip animations or provide instant transitions when this returns true.

Conclusion

Scroll-aware components don’t have to tank your React application’s performance. The key is choosing the right tool for each use case. Reach for Intersection Observer first—it handles visibility detection without performance overhead. When you genuinely need continuous scroll values, bypass React’s state system by using refs and requestAnimationFrame for direct DOM manipulation. For shared scroll state across multiple components, useSyncExternalStore provides a clean, concurrent-safe pattern. Whatever approach you choose, test thoroughly with your framework’s routing and always respect user preferences for reduced motion.

FAQs

Setting state on every scroll event triggers React's reconciliation process 60+ times per second. Each state update causes a re-render, which means React must diff the virtual DOM and potentially update the real DOM repeatedly. This overwhelms the main thread and causes visible jank. Using refs bypasses this entirely since ref updates don't trigger re-renders.

Use useSyncExternalStore when multiple components need the same scroll position value. It properly integrates with React's concurrent rendering features and handles edge cases like tearing, where different components might see different values during a render. It also provides built-in support for server-side rendering through getServerSnapshot.

You can use them with progressive enhancement. CSS scroll-driven animations currently work in Chrome and Edge, with Safari and Firefox support still incomplete or in progress. Implement them as an upgrade for supported browsers while providing a JavaScript fallback or simplified experience for others. Check caniuse.com for current browser support before relying on them.

Test after client-side navigation, not just on initial page load. The App Router manages scroll restoration automatically, which can conflict with custom scroll handlers. Navigate between pages using Link components and verify your scroll listeners reattach correctly and don't interfere with the framework's scroll position restoration behavior.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay