かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React hooks event.target を setTimeout 内で使おうとしたらnullなんだけど、にハマる

Debounce で値を反映したいコンポーネントを作っていて、setTimeout 内で event から渡された event.target を使おうとしたら null になっていてエラーになり、なんもわからん…
になってハマっていたのでメモ

event.target が null になるコード

useDebounceCallback.js

import { useRef, useCallback } from 'react';
export const useDebounceCallback = (callback, delay = 300) => {
  const debounceTimer = useRef(null);

  const dispatch = useCallback((...args) => {
    debounceTimer.current && clearTimeout(debounceTimer.current);
    debounceTimer.current = setTimeout(() => {
      return callback(...args);
    }, delay);
  }, [callback, delay]);

  const cancel = useCallback(() => {
    debounceTimer.current && clearTimeout(debounceTimer.current);
  }, []);

  return [dispatch, cancel];
};

App.js

import React, { useState, useCallback } from 'react';
import useDebounceCallback from './useDebounceCallback';

const App = ({ initValue }) => {
  const [value, setValue] = useState(initValue);
  const updateValue = useCallback((e) => {
    setValue(e.target.value);
  }, []);
  const [onDebounceUpdateValue] = useDebounceCallback(updateValue, 300);
  
  return (
    <>
      <p>{value}</p>
      <input onChange={onDebounceUpdateValue} defaultValue={initValue} />
    </>
  );
}

これで input の値を変更しようとすると setValue(e.target.value) の部分で、TypeError: Cannot read property 'value' of null というエラーが発生してしまいます。
イベント内に console.log() を置いてみると次のような感じになっています。

const updateValue = useCallback((e) => {
  console.log(e.target); // null
  setValue(e.target.value);
}, []);

e.target そのものが null になっているので、e.target.value にアクセスしようとすると JavaScript のエラーが発生してしまうというもの。
イベントで渡される引数を渡しているだけなのに、WHY…

React ではイベントで SyntheticEvent というネイティブのイベントをラップしたインスタンスが渡される

SyntheticEvent では callback イベントが発火し終わると、プロパティは null になる仕様になっているようです。

Event Pooling
The SyntheticEvent is pooled. This means that the SyntheticEvent object will be reused and all properties will be nullified after the event callback has been invoked. This is for performance reasons. As such, you cannot access the event in an asynchronous way.
cf. SyntheticEvent – React

先の例は custom hooks を使ったりで複雑になっているので、問題をシンプルにしてみます。

import React, { useState } from 'react';

const App = ({ initValue }) => {
  const [value, setValue] = useState(initValue);
  const onDebounceUpdateValue = (e) => {
    console.log(e, e.target); // => SyntheticEvent, <input />
    setTimeout(() => {
      console.log(e.target); // => null;
      setValue(e.target.value); // => TypeError: Cannot read property 'value' of null
    }, 300);
    // ここで callback イベントは終了して SyntheticEvent のプロパティは nullified される
  };
  
  return (
    <>
      <p>{value}</p>
      <input onChange={onDebounceUpdateValue} defaultValue={initValue} />
    </>
  );
}

解決方法: 非同期イベント外で変数に使用したい値を格納しておく

const App = ({ initValue }) => {
  const [value, setValue] = useState(initValue);
  const onDebounceUpdateValue = (e) => {
    const target = e.target;
    setTimeout(() => {
      console.log(target); // => <input />;
      setValue(target.value); // => 👌
    }, 300);
  };
  // 略
}

🙅 async / await をしても SyntheticEvent のプロパティは nullified されるのでダメ

非同期イベントを async / await すれば大丈夫なのではと思ったのですが、非同期イベント内では null 化されたものが渡されるようです。

const App = ({ initValue }) => {
  const [value, setValue] = useState(initValue);
  const onDebounceUpdateValue = async (e) => {
    await new Promise((resolve) => {
      return setTimeout(() => {
        console.log(e.target); // => null 😇
        setValue(e.target.value); // => TypeError: Cannot read property 'value' of null
        return resolve();
      }, 1000);
    });
    console.log('callback end');
  };
  // 略
}

最初の useDebounceCallback hooks を使ってる場合はどう修正すればよいのか

custom hook は使いまわしたいので、custom hook 内で const target = e.target の様な事をするのは避けたいところです。
なので、custom hook が返す関数をイベントでそのまま呼び出すのではなく、コンポーネント内でもうひとつ関数で囲ってやればOK。

useDebounceCallback.js 変更なし

import { useRef, useCallback } from 'react';
export const useDebounceCallback = (callback, delay = 300) => {
  const debounceTimer = useRef(null);

  const dispatch = useCallback((...args) => {
    debounceTimer.current && clearTimeout(debounceTimer.current);
    debounceTimer.current = setTimeout(() => {
      return callback(...args);
    }, delay);
  }, [callback, delay]);

  const cancel = useCallback(() => {
    debounceTimer.current && clearTimeout(debounceTimer.current);
  }, []);

  return [dispatch, cancel];
};

App.js

import React, { useState, useCallback } from 'react';
import useDebounceCallback from './useDebounceCallback';

const App = ({ initValue }) => {
  const [value, setValue] = useState(initValue);
  const updateValue = useCallback((value) => setValue(value), []);
  const [debounceUpdateValue] = useDebounceCallback(updateValue, 300);
  const onDebounceUpdateValue = useCallback((e) => {
    debounceUpdateValue(e.target.value); // 必要な値だけを渡してあげる
  }, [debounceUpdateValue]);
  
  return (
    <>
      <p>{value}</p>
      <input onChange={onDebounceUpdateValue} defaultValue={initValue} />
    </>
  );
}

完 全
理 解
₍ᐢ › ༝ ‹ ᐢ₎

note.
  • nullified … 無効化
  • invoke発動する
  • asynchronous … 非同期な

[参考]

みんなでアジャイル ―変化に対応できる顧客中心組織のつくりかた

みんなでアジャイル ―変化に対応できる顧客中心組織のつくりかた

  • 作者:Matt LeMay
  • 発売日: 2020/03/19
  • メディア: 単行本(ソフトカバー)