ReactでIntersectionObserverを使うときはuseSyncExternalStoreが使えそうという話

2024-08-10T14:16:20.545Z

最近はReactしか触ってなさすぎてVueが書けなくなっている系エンジニアのtaconaです。

最近、業務でReactアプリケーションを作成中に「要素がviewportに入った時にfetch処理をしたい」みたいな要件に出くわしました。
シンプルな交差APIであればuseEffectによる実装でも事足りそうではあったんですが、fetchした結果の整形処理などが必要で、fetch処理のたびに交差時に実行するハンドラの再評価が必要なケースにおいて、useEffectでは要件を満たせないケースに直面しました。

今回はReact 18で新たに追加されたuseSyncExternalStoreによっていい感じにできるのでは?という天啓(正確には同僚からの入れ知恵)をきっかけに実際に試してみたので、その経緯をメモがてら書いていきます。

やりたいこと

以下のような要件を満たしたいとします

サンプルコード

taconasu/learn-IntersectionObserver-with-useSyncExternalStore

実際の動作を見るのが最も早いと思うので、useEffectおよびuseSyncExternalStoreそれぞれの動作を確認できるサンプルコードを用意しました。

実装の紹介

useSyncExternalStore パターン

useSyncExternalPatterのサンプル実装に毛(コメント)を追記する形で説明します。

'use client'

import { useCallback, useRef, useSyncExternalStore } from 'react'

type Props = {
  flag: boolean
  onlyOnce?: boolean
  onIntersecting?: (state: boolean) => void
}
export const UseSyncExternalStorePattern = ({ flag, onlyOnce = false, onIntersecting }: Props) => {
  const targetRef = useRef<HTMLDivElement>(null)
  const isIntersectingRef = useRef(false) // 👈 初回レンダリング時にgetSnapshotが実行されないようにしている

  // 👇サブスクライブする関数 要素が画面上に交差したらcallbackを実行してgetSnapshotを実行する
  const subscribe = useCallback(
    (callback: () => void) => {
      console.log('handle Subscribe (useSyncExternalStore)')
      if (!targetRef.current) return () => {}

      const target = targetRef.current

      const handleIntersecting = (entries: IntersectionObserverEntry[]) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            isIntersectingRef.current = true
            callback()
            if (onlyOnce) observer.unobserve(target)
          }
        })
      }
      const observer = new IntersectionObserver(handleIntersecting)
      observer.observe(target)

      return () => {
        observer.unobserve(target)
      }
    },
    [onlyOnce],
  )

  // 👇交差した時に実行するための関数
  const getSnapshot = () => {
    // 画面の交差時のみ実行されるよう、初回表示時などではearly returnしている
    if (!isIntersectingRef.current) return
    console.log('Run getSnapshot(useSyncExternalStore)')

    if (onIntersecting) onIntersecting(flag)

    isIntersectingRef.current = false
  }

  useSyncExternalStore(subscribe, getSnapshot, getSnapshot) // 第三引数はServerSide向け( https://ja.react.dev/reference/react/useSyncExternalStore#parameters )

  return (
    <div ref={targetRef}>
      この要素がviewportに入ると、onIntersectingが実行されます(useSyncExternalStore pattern)
    </div>
  )
}

実際の動作については百聞は一見にしかずなので、cloneしてpnpm devしてください。

useSyncExternalStoreは名称の通り基本的にはReact外部のstoreを取り回すことを想定しているhooksのため、サブスクライブしている交差時のタイミング以外では実行されないように一工夫入れる必要があります。(isIntersectingRefがそれにあたります)

useEffect パターン

useEffectのサンプル実装に毛(コメント)を追記する形で説明します。

import { useEffect, useRef } from 'react'

type Props = {
  flag: boolean
  onlyOnce?: boolean
  onIntersecting?: (state: boolean) => void
}
export const UseEffectPattern = ({ flag, onlyOnce, onIntersecting }: Props) => {
  const targetRef = useRef<HTMLDivElement>(null)
  const intersectedRef = useRef(false) // 👈 初回の交差処理が実行済みかどうかを判定する

  useEffect(() => {
    console.log('handle Subscribe (useEffect)')
    // 👇 初回の交差だけ実行したい場合はearly return するようにしている
    if (!targetRef.current || (intersectedRef.current && onlyOnce)) return

    const target = targetRef.current

    const callback = (entries: IntersectionObserverEntry[]) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          intersectedRef.current = true // 👈 交差したらtrueになる
          if (onIntersecting) onIntersecting(flag)
        }
      })
    }
    const observer = new IntersectionObserver(callback)
    observer.observe(target)

    return () => {
      observer.unobserve(target)
    }
    // NOTE: 依存配列を空にすることで交差時の無限ループは防げるが、そうすると初回描画時のstateの状態でしか評価できなくなる
  }, [flag, onIntersecting, onlyOnce])

  useEffect(() => {
    // 途中からonlyOnceフラグをfalse(監視し続ける状態)に変更した場合、制御用のフラグをfalseにする
    if (!onlyOnce) intersectedRef.current = false
  }, [onlyOnce])

  return (
    <div ref={targetRef}>
      この要素がviewportに入ると、onIntersectingが実行されます(useEffect pattern)
    </div>
  )
}

NOTEコメントの内容が、useEffectでは難しいパターンの原因です。

useEffectによって初回表示時および依存配列に指定しているハンドラなどの情報の更新時に監視処理を初期化しなおすといった実装をしています。

useEffectが問題になるケース

「やりたいこと」にある以下の要件がある場合、useEffectだと意図通りに処理を実行することが難しいです。(厳密にはできるのかもしれませんが、僕はいい感じの実装が思いつきませんでした)

交差時の処理時にAPI経由で取得したデータを参照する必要がある

useEffectパターンでは交差時の処理をuseEffectの依存配列に指定しているため、ハンドラの更新が発生した場合はuseEffectが再評価されます。
その結果、新たにIntersectionObserverインスタンスによって監視が始まり、新しいインスタンスの交差処理が再度実行されます。その交差処理でもまたハンドラの更新が発生し、useEffectが再評価され...というのを繰り返し、viewport上に監視対象の要素が存在する限り無限に交差処理が実行されるようになってしまいます。

活字だけではイメージできない場合、pnpm devして「useEffectだと難しいサンプルコード」の動作を試してconsole.log出力を体感してください🙂

その場合の親コンポーネントの実装(から余分なコードを排除した状態)を以下に貼り付けつつ、コメントで補足します。

page.tsx

'use client'

import { UseEffectPattern } from '@/components/UseEffectPattern'
import { UseSyncExternalStorePattern } from '@/components/UseSyncExternalStorePattern'
import { useCallback, useState } from 'react'

export default function Home() {
  const [enabledIntersection, setEnabledIntersection] = useState(false)
  const [onlyOnce, setOnlyOnce] = useState(false)

  // useEffectだとうまくいかないパターン
  const [count, setCount] = useState(0)
  const handleOnIntersectingCount = useCallback(() => {
    console.log('🐙 Intersecting times:', count)
    setCount((prev) => prev + 1)
  }, [count]) // 👈 依存配列に更新対象のstateが含まれているため、setCountを実行するとこの関数は再評価される

  return (
    <main className="flex flex-col">
      <div className="flex flex-col gap-4 items-center justify-center h-svh p-24 mb-11">
        <h1>useEffectだとうまくいかないパターン</h1>
        <p>
          enabled intersection is{' '}
          {enabledIntersection ? 'true(onIntersectingを実行します)' : 'false(onIntersectingを実行しません)'}
        </p>
        <button onClick={() => setEnabledIntersection(!enabledIntersection)}>
          click!
        </button>
      </div>
      <UseSyncExternalStorePattern
        flag={enabledIntersection}
        onlyOnce={onlyOnce}
        onIntersecting={enabledIntersection ? handleOnIntersectingCount : undefined}
      />
      <UseEffectPattern
        flag={enabledIntersection}
        onlyOnce={onlyOnce}
        onIntersecting={enabledIntersection ? handleOnIntersectingCount : undefined}
      />
    </main>
  )
}

上記のように実行するたびに自身を再評価する必要がある関数を交差時のハンドラとして渡している場合、useEffectパターンではviewportに入っている間無限にsetCountが呼ばれ続けます。
一方で、useSyncExternalStoreパターンではサブスクライブしているIntersectionObserverの交差時にcallbackを呼ぶことで、その時のstateを参照して一度だけ実行できるため、関数の再評価などが発生せずに意図通りに交差時に一度だけ実行されます。

まとめ

単純に交差時にちょっとしたアニメーションを実行したい。といった用途ならuseEffectでも事足りると思います。useSyncExternalStoreを使ってIntersectionObserverの監視をするのは若干ハックみがあり、コードから挙動を予測しやすいかと言われるとそうでもない気もするので上述したような「交差時の処理によって、ハンドラの再評価をしないといけないケース」でない限りはuseEffectを使うでもいいかもしれません。

ただ、useSyncExternalStoreの場合は無闇にIntersectionObserverのインスタンスを再生成しないですし、callbackが呼ばれるタイミングでgetSnapshotによってstateの情報を正しく参照できるため、それなりに扱いやすさも感じました。あんまり実装を意識しなくて済むように交差APIに関連する処理をカスタムフックで切り出して上げられると使いやすくていいかも。と思いました。

こんな要件にそうそう出くわすかはわからないですが、誰かの役に立つといいなー。

追記

必要な情報を引数に渡すだけでお手軽に要素の交差を監視できるuseIntersectionフックを実装してみました。

呼び出し側では以下のようにフックを呼ぶだけ!お手軽!

'use client'

import { useIntersection } from '@/hooks/useIntersection'
import { useRef } from 'react'

type Props = {
  flag: boolean
  onlyOnce?: boolean
  onIntersecting?: (state: boolean) => void
}
export const UseIntersectionHooksPattern = ({ flag, onlyOnce = false, onIntersecting }: Props) => {
  const targetRef = useRef<HTMLDivElement>(null)

  // 👇 こんな風に呼び出すだけ!
  useIntersection<HTMLDivElement>(
    // 第一引数: 交差を監視する対象の要素(ref)
    targetRef.current,
    // 第二引数: 交差時に実行したいハンドラ
    () => {
      // 交差時に実行させたくないシチュエーションがある場合はここで条件分岐を行う
      if (onIntersecting) onIntersecting(flag)
    },
    // 第三引数: ハンドラの実行頻度(既定値はfalse)
    onlyOnce, // 👈 交差のたびに実行したい場合は省略可能
  )

  return (
    <div ref={targetRef} className="bg-violet-300 p-4">
      この要素がviewportに入ると、onIntersectingが実行されます(useSyncExternalStore pattern)
      <br />
      onlyOnceがtrueの場合、一度だけ実行されます
      <br />
      onIntersectingがundefinedの場合、交差してもハンドラは実行されません(この場合でも、交差フラグは評価します)
    </div>
  )
}