0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React で `lodash.throttle` や `setTimeout` など遅延関数を使う方法の模索

Posted at

この記事は何?

React でlodashdebouncethrottleを使う方法はないかと探している人向けの記事。

安直にuseCallback等を使えば行けるのでは?と考えがちになってしまう自分が今後同じ轍を踏まないために経験を記す。

結論

  • lodashは React 向けのライブラリではないのでラッピングが大変面倒でかつ使用状況に制限がある
  • setTimeoutuseEffectの組み合わせがシンプルで分かりやすい
  • setTimeoutuseEffectからなるカスタム・フックは、汎用的にするよりも使用場面に特化させれば便利である
  • 一番手っ取り早いのはreact-useの各タイマー関数を使うべき

Lodash throttleは React で使えるか?

※class コンポーネントで使う場合を扱いません

React では純粋関数が求められている。

そのため、

const delayedFunc = throttle(() => onMouseMoveHandler(), 1000);

を React 関数内で定義しても、毎レンダリング時にdelayedFuncは再作成される。

再作成されるために、遅延効果が発生しない。

再レンダリング前の呼び出しによるタイマーは、再レンダリングによって以前の関数が消え、タイマーがリセットされるためである。

React でこうした遅延関数を使う方法がないか模索する。

lodash.throttle + useCallback

useCallbacKは、依存関係が変化するたびに再作成されるので、再作成前の関数と別物になることから遅延を発生させる機能を持った関数に使うべきでない。

ただしuseCallbacKの依存関係に何も含めないならば、つまり一度throttledを生成して以降変更させないならば使うことができる。

// GOOD: 依存関係がなく再作成されないから。
const throttled = useCallback(throttle(newValue => console.log(newValue), 1000), []);

// BAD:依存関係の変化のたびに`throttled`は「新しくなる」。
const throttled = useCallback(throttle(() => console.log(value), 1000), [value]);

後者は value の値が変化するたびに遅延を発生させずに即時実行されてしまう。

検証: lodash.throttle + useCallbackにおいてuseCallbackの依存関係有無によって結果が変わるか

useCallbackに依存関係を含めると、毎レンダリング時に再生成される例:

input フォームへ連続で入力を続けたら、1秒ごとにコンソールへログを出力するはずが毎入力ごとに反応している。

import React, { useCallback, useState } from "react";
import throttle from "lodash.throttle";

const ReactalizeLodash = () => {
  const [value, setValue] = useState<string>("initial");

  const throttled = useCallback(
    throttle(() => console.log(value), 1000),
    [value]
  );

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.currentTarget.value);
    throttled();
  };
  return (
    <div className="reactalize-lodash">
      <input onChange={handleChange} type="text" />
    </div>
  );
};

依存関係を含めないとそれは起こらない。

import React, { useCallback, useState } from "react";
import throttle from "lodash.throttle";

const ReactalizeLodash = () => {
  const [value, setValue] = useState<string>("initial");

  const throttled = useCallback(
    throttle((val) => console.log(val), 1000),
    []
  );

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.currentTarget.value);
    throttled(e.currentTarget.value);
  };
  return (
    <div className="reactalize-lodash">
      <input onChange={handleChange} type="text" />
    </div>
  );
};

ということで、

依存関係なしuseCallbackで throttle を囲う方法はアリ。

依存関係を含めなくてはならない場合はlodash.throttleの使用を見直す必要がある。

useRef + useEffect

そもそも lodash は React 向けに作られているわけじゃないのでuseRef + useEffectの組み合わせで React の理の外に出すべき。

import React, { useRef, useState, useEffect } from "react";
import throttle from "lodash.throttle";

const ThrottleNRef = () => {
  const [value, setValue] = useState<string>("initial");
  const refThrottle = useRef(
    throttle((newValue) => console.log(newValue), 1000)
  );

  useEffect(() => {
    if (refThrottle.current) {
      refThrottle.current(value);
    }
  }, [value]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.currentTarget.value);
  };

  return (
    <div className="reactalize-lodash">
      <input onChange={handleChange} type="text" />
    </div>
  );
};

export default ThrottleNRef;

ここまでしてlodash.throttleを使う価値があるのかは使う人の事情によるとして、

シンプルな使い方を求めるならやはり後で述べるようにsetTimeoutを使うのがいいと思う。

遅延関数を作るのに lodash を使わない例

結局setTimeout, clearTimeoutの組み合わせてカスタム HOOK にするのが使いやすい。

どう便利なのかは以下の例をみればわかるかも。

参考:

setTimeout + useEffect

要素の連続で発生する onresize イベントに対して一番最後のイベントにのみハンドラが反応するようにタイマーを使う。

サンプルコードの説明:

リサイズ可能な要素をリサイズすると、リサイズしてから遅れてリサイズ後の幅の値をログに出力する。

処理の流れ:

  • onresize イベントが発生する
  • sectionWidthが更新される
  • useEffect(,[sectionWidth])が実行される
  • タイマーがセットされる
  • 500 ミリ秒経過する間にuseEffect(,[sectionWidth])が再度呼び出されなければsetTimeoutのコールバックが実行されて、経過前に再度呼び出されればclearTimeoutされる。
const Timer = () => {
  const [sectionWidth, setSectionWidth] = useState<number>(300);

  useEffect(() => {
    const timer = setTimeout(() => {
      console.log(sectionWidth);
    }, 500);

    return () => clearTimeout(timer);
  }, [sectionWidth]);

  const onEditorSecResize: (
    e: React.SyntheticEvent,
    data: ResizeCallbackData
  ) => any = (event, { node, size, handle }) => {
    setSectionWidth(size.width);
  };

  return (
    <>
      <ResizableBox
        width={sectionWidth}
        height={Infinity}
        minConstraints={[100, Infinity]}
        maxConstraints={[600 * 0.8, Infinity]}
        onResize={onEditorSecResize}
        resizeHandles={["e"]}
        handle={(h, ref) => (
          <span className={`custom-handle custom-handle-${h}`} ref={ref} />
        )}
      >
        <div className="resizable-section" style={{ width: sectionWidth }}>
          <div
            className="box"
            style={{
              width: sectionWidth,
              height: "400px",
              backgroundColor: "#47487a"
            }}
          ></div>
        </div>
      </ResizableBox>
    </>
  );
};

こちらのほうが心理的に安心。

lodash という JavaScript 向けライブラリを React でどうにか使う場合はどこか見落としがないか常に不安なので。

タイマーを発生させるかどうかはどの値をuseEffectの依存関係に渡すかで決められて便利。

よく見かけるカスタム・フック化について

よくみるやつ。

以下の例はネットで検索するとしょっちゅう出てくるよくない例。

import React, { useEffect, useRef } from "react";

/**
 * cb: タイマーで実行させたいコールバック関数
 * delay: setTimeoutのdelay引数
 * trigger: `useEffect()`に渡す依存関係で同時に実行のトリガー
 * refCb: 渡されたcb引数を参照する。
 * */
const useTimer = (cb: () => void, delay: number, trigger: any) => {
  const refCb = useRef<() => void>();

  useEffect(() => {
    // DEBUG:
    console.log("[useTimer] This side effect runs every call useTimer.");

    if (refCb.current === undefined) {
      refCb.current = cb;
    }
  }, [cb]);

  useEffect(() => {
    const timer = setTimeout(() => {
      if (refCb.current) {
        refCb.current();
      }
    }, delay);

    return () => clearTimeout(timer);
  }, [trigger, delay]);
};

export default useTimer;


// 呼び出し側
  useTimer(() => console.log(sectionWidth), 1000, sectionWidth);

これの問題点はuseEffect(,[cb])が毎度呼び出されることである。

引数cb() => console.log(sectionWidth))は毎呼び出し新しく生成され

cb引数は常に前回と異なるものだから必ず毎度useEffect(,[cb])が実行される。

そのためuseRefの存在意義が失われている。

こうなった場合、cbは引数で受け取ることをやめてハードコードしたほうがいいかも。

汎用性が下がるけど、ある使用場面に特化させたほうが余計な処理もなくわかりやすい。

最後のonresizeイベントにのみ反応して DOMRect を返すカスタム・フック

例:onresizeイベントハンドラの発火にたいしてタイマーを設け、タイマーが切れたらある DOM の DOMRect を取得して返す

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

/***
 * 呼び出し側で発生したonresizeイベントに応じてタイマーをセットし、
 * 次のタイマーの呼び出しがなかった時に
 * `refDom`の指すDOMからDOMRect情報を取得し返す
 *
 * @param {number} delay - Delayed time of setTimeout. Also time from call to completion.
 * @param {number} width - Width of element which fired resize event on caller.
 *
 * ある要素のリサイズの後にDOMRect情報が欲しいときに使うと便利。
 * */
const useDelayedRect = <T extends HTMLElement = HTMLDivElement>(
  delay: number,
  width: number
) => {
  const [rect, setRect] = useState<DOMRect>();
  const refDom = useRef<T>(null);

  useEffect(() => {
    if (refDom.current === undefined || !refDom.current) return;
    const timer = setTimeout(() => {
      const _rect = refDom.current!.getBoundingClientRect();
      setRect(_rect);
    }, delay);

    return () => clearTimeout(timer);
  }, [width, delay]);

  return [rect, refDom];
};

export default useDelayedRect;

使い方:

import React, { useEffect, useState } from "react";
import { ResizableBox } from "react-resizable";
import type { ResizeCallbackData } from "react-resizable";
import useDelayedRect from "./useDelayedRect";

const ResizableContainer = () => {
  const [sectionWidth, setSectionWidth] = useState<number>(300);
  // タイマー1000ミリ秒
  // 実行トリガー:`sectionWidth`が更新したら
  const [rect, refDom] = useDelayedRect<HTMLDivElement>(1000, sectionWidth);

  useEffect(() => {
    console.log(rect);
  }, [rect]);

  const onEditorSecResize: (
    e: React.SyntheticEvent,
    data: ResizeCallbackData
  ) => any = (event, { node, size, handle }) => {
    setSectionWidth(size.width);
  };

  return (
    <>
      <ResizableBox
        width={sectionWidth}
        height={Infinity}
        minConstraints={[100, Infinity]}
        maxConstraints={[600 * 0.8, Infinity]}
        onResize={onEditorSecResize}
        resizeHandles={["e"]}
        handle={(h, ref) => (
          <span className={`custom-handle custom-handle-${h}`} ref={ref} />
        )}
      >
        <div
          className="resizable-container"
          style={{ width: sectionWidth }}
          // DOMRectを取得する要素のrefへrefDomを渡す
          ref={refDom}
        >
          <div
            className="box"
            style={{
              width: sectionWidth,
              height: "400px",
              backgroundColor: "#47487a"
            }}
          ></div>
        </div>
      </ResizableBox>
    </>
  );
};

export default Timer;

ResizableBoxを水平方向にリサイズすると、

リサイズ中はuseDelayedRect(の setTimeout コールバック)は実行されず、

リサイズを終えたら実行され、rectが更新される。

余談:lodashライブラリ全部インストールしてはならない

lodash ライブラリは巨大である。

$ npm install lodash

とそのまますべてインストールしてしまうと、

webpack 環境などだと lodash ライブラリすべてをバンドルするので

lodash のうち使っていないライブラリコードがアプリケーションに大量に含まれることになる。

そのため使うライブラリだけインストールするようにする。

debounceだけ使いたいならば

$ npm install --save lodash.debounce
$ npm install --dev @types/lodash.debounce
import debounce from 'lodash.debounce';

参考

react-useではuseDebounceと名付けつつ中身はsetTimeoutを使っていた。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?