🎬

DOM要素の伸縮完了後にCallbackを実施する

2024/03/21に公開

アルダグラムでエンジニアをしている松田です。

UI操作により、動的に変化する要素があるとします。
要素の伸縮動作の完了を見届けた後に、何らかの処理を行いたい…
そのようなユースケースで、本稿の内容が参考になれば幸いです。


要素を参照する方法 🎯

まず、要素を参照する方法を考えてみます。

useRef + useEffect ? 🤔

素朴に実装しようとすると、以下のようになるかもしれません。

  • useRefを用いて、ref objectを用意する
  • ref objectを対象要素のref属性に指定する
  • useEffectでマウント時に、ref objectを参照する
import React from 'react'

export const SomeComponent: React.FC = () => {
  const ref = React.useRef(null)

  React.useEffect(() => {
    console.log(ref.current)
  }, [])

  return (
    <>
      <div ref={ref} />
    </>
  )
}

ただ、以下のように、参照する対象の要素が動的に表示される場合は、useRefとuseEffectの実装では、うまく対応できないケースがあります。

import React from 'react'

export const SomeComponent: React.FC = () => {
  const ref = React.useRef(null)
  const [show, setShow] = React.useState(false)

  React.useEffect(() => {
    // マウント時には参照が取得できないため、ref.currentはnullとなる
    console.log(ref.current)
  }, [])

  return (
    <>
      <button onClick={() => setShow(!show)}>表示 / 非表示</button>
      {/* マウント時は要素自体が存在しない (参照不可) */}
      {show && <div ref={ref} />}
    </>
  )
}

このような場合は、どのようにして要素を参照すればよいのでしょうか?

Callback ref!💡

解決策の1つとして、 callback ref があります。

実は、ref属性にはref objectだけではなく、対象の要素を引数として扱うcallbackを指定することもできます。

https://ja.react.dev/reference/react-dom/components/common#ref-callback

これを用いると、対象要素のレンダリング後に確実に参照処理を行うことができます。

そして、useRefとuseEffectは不要となります。

import React from 'react'

export const SomeComponent: React.FC = () => {
  const [show, setShow] = React.useState(false)

  return (
    <>
      <button onClick={() => setShow(!show)}>表示 / 非表示</button>
      {show && <div ref={node => console.log(node)} />}
    </>
  )
}

callback refに関する詳細については、以下の記事が秀逸です。

https://tkdodo.eu/blog/avoiding-use-effect-with-callback-refs


要素のサイズを監視 🔭

要素の参照方法には見当が付きました。

続いて、要素のサイズ変更を監視する方法を考えます。

ResizeObserver 🔍

シチュエーションによっては、 MutationObserver や IntersectionObserver が有用なケースもあると思いますが、 ResizeObserver による監視であれば、要素の伸縮時に確実にcallbackが実施されると思います。

https://developer.mozilla.org/ja/docs/Web/API/ResizeObserver

使用方法を簡単に説明すると、以下のようになります。

  • new ResizeObserver() でobserverのオブジェクトを生成
    • この時にresize時に発動するcallbackも指定
  • observe() で監視を開始
  • unmount時など、監視が不要なったらunobserve()やdisconnect()で監視を解除
const resizeObserver = new ResizeObserver((entries) => {
  // resize時に実施したい処理
})

// 監視の開始
// callbackのentriesに渡されるResizeObserverEntryが増える
resizeObserver.observe(targetElement)

// 指定された要素の監視を解除
resizeObserver.unobserve(targetElement)

// 全要素の監視の解除
resizeObserver.disconnect()

Callback ref との併用 🍻

ResizeObserverをcallback ref内で使用することで、対象要素のレンダリング時に適切にサイズ変更の監視を行えるようになります。

また、ResizeObserverのinstanceをrefに格納し、初期化確認するようにすれば、再レンダリングなどによるobserverの増殖も防止できます。

import React from "react";
import { FC, useRef, useState } from "react";

export const SomeComponent: FC = () => {
  const resizeObserverRef = useRef<ResizeObserver>();
  const [show, setShow] = useState(false);
  const [height, setSetHeight] = useState(0);

  // callback ref
  const targetElementCallbackRef = (targetElement: HTMLElement | null) => {
    // resizeObserverが初期化されていない場合、初期化する
    if (!resizeObserverRef.current) {
      const resizeObserver = new ResizeObserver((entries) => {
        // ここでobserverEntryを用いて高さを取得する
        setSetHeight(entries[0].contentRect.height);
      });
      resizeObserverRef.current = resizeObserver;
    }

    if (targetElement !== null) {
      // mount時に監視を開始する
      resizeObserverRef.current?.observe(targetElement);
    } else {
      // unmount時に監視を解除する
      resizeObserverRef.current?.disconnect();
    }
  };

  return (
    <>
      <button onClick={() => setShow(!show)}>{show ? "非表示" : "表示"}</button>
      {show && (
        <div
          ref={targetElementCallbackRef}
          style={{
            overflow: "hidden",
            resize: "both",
            background: "#0FF",
            border: "2px solid #000",
          }}
        >
          {`高さ: ${height}px`}
          <br />
          {`右下のハンドルでresizeできます`}
        </div>
      )}
    </>
  );
};

export default SomeComponent;


伸縮動作が落ち着いてからCallbackを発火したい 📛

ResizeObserverとCallback refを用いることで、要素のサイズの変更を検知することはできました。

ただ、他の処理との兼ね合いやパフォーマンスの関係で、サイズ取得の頻度を落としたい場合があるかもしれません。

useDebouncedCallback 🎳

react-hookz から useDebouncedCallback というhooksが提供されています。

https://react-hookz.github.io/web/?path=/docs/callback-usedebouncedcallback--example

以下のようにhooksに引数を渡すだけで、閾値時間以下の高頻度な更新を無視するcallbackを返却してくれます。

  • 第1引数にcallbackの処理 (useCallbackと同様)
  • 第2引数に依存配列 (useCallbackと同様)
  • 第3引数に閾値 [ms]
    • この閾値以下の時間内に生じた更新は無視されます
import React from "react";
import { FC, useRef, useState } from "react";
import { useDebouncedCallback } from "@react-hookz/web";

export const SomeComponent: FC = () => {
  const resizeObserverRef = useRef<ResizeObserver>();
  const [show, setShow] = useState(false); // 要素の表示/非表示を切り替えるstate
  const [height, setSetHeight] = useState(0); // 要素の高さを保持するstate

  // debounceの閾値を調整
  // 100ms以下の変更は無視される
  const debounceDelay = 100;

  // debounceを伴って、要素の高さを取得するcallback
  const debouncedSetTableHeight = useDebouncedCallback(
    (height: number) => setSetHeight(height),
    [setSetHeight],
    debounceDelay
  );

  // callback ref
  const targetElementCallbackRef = (targetElement: HTMLElement | null) => {
    // resizeObserverが初期化されていない場合、初期化する
    if (!resizeObserverRef.current) {
      const resizeObserver = new ResizeObserver((entries) => {
        // ここでobserverEntryを用いて高さを取得する
        // debounceを伴うので、更新頻度はdebounceDelayに依る
        debouncedSetTableHeight(entries[0].contentRect.height);
      });
      resizeObserverRef.current = resizeObserver;
    }

    if (targetElement !== null) {
      // mount時に監視を開始する
      resizeObserverRef.current?.observe(targetElement);
    } else {
      // unmount時に監視を解除する
      resizeObserverRef.current?.disconnect();
    }
  };

  return (
    <>
      <button onClick={() => setShow(!show)}>{show ? "非表示" : "表示"}</button>
      {show && (
        <div
          ref={targetElementCallbackRef}
          style={{
            overflow: "hidden",
            resize: "both",
            background: "#0FF",
            border: "2px solid #000",
          }}
        >
          {`高さ: ${height}px`}
          <br />
          {`右下のハンドルでresizeできます`}
        </div>
      )}
    </>
  );
};

export default SomeComponent;


あとがき

以上のように、callback ref、ResizeObserver、useDebouncedCallback を用いることで、要素の伸縮完了後に適切なタイミングでcallbackを実施することができます。

実は、似たようなことは、 react-hookz の useMeasure でも実現できます。

https://react-hookz.github.io/web/?path=/docs/sensor-usemeasure--example

単純に要素の高さと幅を監視したいだけであれば、こちらを用いる方が簡潔でしょう。

ただ、Ref callbackでobserverの設定以外を実施したい場合や、Debounceの頻度をチューニングしたい場合などは、本稿の内容が役立てるかもしれません。


もっと、アルダグラムのエンジニア組織を知りたい人は、下記の情報をチェック!

アルダグラム Tech Blog

Discussion