Back

Reactでスクロールアウェアコンポーネントを構築する

Reactでスクロールアウェアコンポーネントを構築する

スクロールハンドラーは1秒間に60回発火します。コンポーネントは毎回再レンダリングされます。ユーザーにはスムーズなアニメーションではなくカクつきが表示されます。このパターンは、ほぼすべてのパフォーマンス上のミスよりも速くReactアプリケーションを破壊します。

Reactでスクロールアウェアコンポーネントを構築するには、Reactのstate系を完全に避けるべきタイミングを理解する必要があります。この記事では、Reactスクロール位置トラッキングに適したツール、なぜReactでのIntersection Observerをデフォルトの選択肢にすべきか、そして本当に生のスクロールデータが必要な場合にパフォーマンスの高いスクロールハンドリングを実装する方法について説明します。

重要なポイント

  • Intersection Observerは、パフォーマンスコストなしでほとんどのスクロールアウェアパターンを処理します
  • 連続的なスクロール値が必要な場合は、stateの代わりにrefsとrequestAnimationFrameを使用してください
  • useSyncExternalStoreは、コンポーネント間で共有されるスクロールstateのために予約しておきましょう
  • フレームワークのルーティングでテストし、常にreduced-motionの代替手段を提供してください

なぜIntersection Observerを最初の選択肢にすべきか

ほとんどのスクロール駆動UIのReactパターンは、実際にはスクロール位置を必要としません。必要なのは可視性の検出です:この要素は画面上にあるか?ユーザーはこのセクションをスクロールして通過したか?

Intersection Observerは、すべてのスクロールイベントでアプリケーションコードを実行することなく、これらのケースを処理します。ブラウザは監視を内部的に最適化するため、スクロールリスナーよりも劇的にパフォーマンスが高くなります。

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>
  );
}

react-intersection-observerライブラリは、このAPIをきれいにラップしています。セクショントラッキング、遅延読み込み、リビールアニメーションに使用してください。

実際にスクロール位置が必要な場合

一部のパターンは連続的なスクロール値を必要とします:パララックス効果、進行状況インジケーター、またはスクロールにリンクされた変形です。ここで、ほとんどのチュートリアルは失敗します。

絶対にこれをしないでください:

// これはすべてのスクロールティックで再レンダリングを引き起こします
const [scrollY, setScrollY] = useState(0);

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

このパターンは、アクティブなスクロール中に1秒間に60回以上Reactの再調整プロセスをトリガーします。

Refベースのパターン

スクロール値をrefsに保存し、DOMを直接更新します:

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>;
}

重要な詳細:requestAnimationFrameは更新をディスプレイのリフレッシュレートに調整します。{ passive: true }オプションは、preventDefault()を呼び出さないことをブラウザに伝え、スクロールの最適化を可能にします。

共有スクロールStateのためのuseSyncExternalStore

複数のコンポーネントがスクロール位置を必要とする場合、それを外部ストアとして扱います:

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
  );
}

このパターンはReactの並行機能と正しく動作し、サーバーサイドレンダリングを処理します。実際には、すべてのスクロールフレームで再レンダリングを避けるために、更新をスロットルするか、粗粒度化(たとえば、生のピクセルの代わりにセクション変更を発行するなど)する必要があります。

スクロールのためのuseLayoutEffect vs useEffect

ブラウザが描画する前にDOM要素を測定する必要がある場合は、useLayoutEffectを使用してください。これにより、getBoundingClientRect()で位置を計算する際の視覚的なちらつきを防ぎます。

スクロールリスナーをアタッチする場合はuseEffectを使用してください。リスナーのセットアップは描画をブロックする必要がありません。

フレームワークに関する考慮事項

Next.js App Routerや同様のフレームワークは、スクロール復元を自動的に管理します。カスタムスクロールハンドラーはこの動作と競合する可能性があります。クライアントサイドナビゲーション後にスクロールアウェアコンポーネントをテストして、復元の問題をキャッチしてください。

CSSスクロール駆動アニメーション

最新のブラウザは、純粋なCSSスクロール効果のためにanimation-timeline: scroll()をサポートしています。ブラウザのサポートは不完全なままですが、このアプローチはサポートされているケースでJavaScriptを完全に排除します。プログレッシブエンハンスメントで使用してください—サポートされていないブラウザのためのフォールバック体験を提供します。

アクセシビリティ

常にprefers-reduced-motionを尊重してください。スクロールトリガーアニメーションをラップします:

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

これがtrueを返す場合は、アニメーションをスキップするか、即座のトランジションを提供してください。

まとめ

スクロールアウェアコンポーネントは、Reactアプリケーションのパフォーマンスを低下させる必要はありません。重要なのは、各ユースケースに適したツールを選択することです。まずIntersection Observerに手を伸ばしてください—パフォーマンスのオーバーヘッドなしで可視性検出を処理します。本当に連続的なスクロール値が必要な場合は、refsとrequestAnimationFrameを使用して直接DOM操作を行い、Reactのstate系をバイパスしてください。複数のコンポーネント間で共有されるスクロールstateには、useSyncExternalStoreがクリーンで並行安全なパターンを提供します。どのアプローチを選択しても、フレームワークのルーティングで徹底的にテストし、常にreduced motionに対するユーザー設定を尊重してください。

よくある質問

すべてのスクロールイベントでstateを設定すると、1秒間に60回以上Reactの再調整プロセスがトリガーされます。各state更新は再レンダリングを引き起こし、Reactは仮想DOMを差分し、実際のDOMを繰り返し更新する可能性があります。これによりメインスレッドが圧倒され、目に見えるカクつきが発生します。refを使用すると、ref更新は再レンダリングをトリガーしないため、これを完全にバイパスできます。

複数のコンポーネントが同じスクロール位置の値を必要とする場合にuseSyncExternalStoreを使用してください。Reactの並行レンダリング機能と適切に統合され、レンダリング中に異なるコンポーネントが異なる値を見る可能性があるティアリングなどのエッジケースを処理します。また、getServerSnapshotを通じてサーバーサイドレンダリングの組み込みサポートも提供します。

プログレッシブエンハンスメントと一緒に使用できます。CSSスクロール駆動アニメーションは現在ChromeとEdgeで動作しますが、SafariとFirefoxのサポートはまだ不完全または進行中です。サポートされているブラウザのアップグレードとして実装し、他のブラウザにはJavaScriptフォールバックまたは簡略化された体験を提供してください。依存する前に、caniuse.comで現在のブラウザサポートを確認してください。

初期ページロードだけでなく、クライアントサイドナビゲーション後にテストしてください。App Routerはスクロール復元を自動的に管理するため、カスタムスクロールハンドラーと競合する可能性があります。Linkコンポーネントを使用してページ間を移動し、スクロールリスナーが正しく再アタッチされ、フレームワークのスクロール位置復元動作を妨げないことを確認してください。

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