12
7

More than 3 years have passed since last update.

エーッ!HooksでcomponentDidUpdate出来ないんですか!!?

Last updated at Posted at 2019-12-10

こんにちは!Hanakla(@hanak1a_)です!
React Hooks Advent Calendar 11日目となるこの記事では、「意外と実装できないuseEffectでcomponentDidUpdateするアレ」を実装した小話をします。

TL;DR

使って、どうぞ。

useUpdateEffect.ts
import { useRef, useEffect, DependencyList } from 'react';

export const useUpdateEffect = <T extends DependencyList>(
  effect: (...args: T) => void | (() => void | undefined),
  watch: T,
  deps: DependencyList = [],
) => {
  const prevWatch = useRef<T | null>(null);

  useEffect(() => {
    if (prevWatch.current == null) {
      prevWatch.current = watch;
      return;
    }

    // React Hooksの内部の比較処理とObject.isを使ってない以外は同等の変更検出処理
    for (const idx in watch) {
      if (prevWatch.current[idx] !== watch[idx]) {
        const prev = prevWatch.current;
        prevWatch.current = watch;
        return effect(...prev);
      }
    }
  }, [...watch, ...deps]);
};
使い方.ts
import { useUpdateEffect } from './useUpdateEffect';

const SomeComponent = ({ lastUpdate }: { lastUpdate: number }) => {
  useUpdateEffect((prevLastUpdate) => {
    // Do something when `lastUpdate` changed
  }, [ lastUpdate ], [ /* なんか依存してる変数があればここに */ ]);

 return null;
};

componentDidUpdateは素直にhooksに置き換えられない

みなさんHooks使ってますか? 局所的に生じるViewの重い処理をuseMemoでキャッシュさせて高速化できると気持ちいいですね。

さて、Class ComponentからFunction Component w/ Hooksへの置き換えをしていると、実はcomponentDidUpdateと同じような挙動をしてくれるhooksがないということに気づきました。

一番近いものでuseEffectなんですが、これはcomponentDidUpdateとは実行タイミングが異なります。

  • componentDidUpdate
    • Componentがマウントされても実行されない
    • Componentが更新されたら実行される
  • useEffect
    • Componentがマウントされたらまず実行される
    • depsに更新があれば実行される / depsが未指定なら更新ごとに実行される

さて、ざっくりした要件としてはつまり初回マウント時の処理を無視してあげるuseEffectを作ればいいという感じですね。じゃあ作ってみましょう。

import { useRef, useEffect } from 'react';

export const useUpdateEffect = (
  effect: () => (void | () => void | undefined), 
) => {
  // useState使うとレンダリング走りそうで嫌なので`useRef`を使う
  const initial = useRef(true);

  useEffect(() => {
   if (initial.current) {
     initial.current = false;
     return;
   }

    return effect();
  });
};

はい。一番愚直なcomponentDidUpdateのhooks版の原型です。(たぶん実行タイミングが若干違うと思うが)じゃあもうちょっとcomponentDidUpdateに合わせて、直前の値を使えるようにしてみましょう。

まずインターフェースとしてはこんな感じですかね

useUpdateEffect((prev) => { /* effect */ }, [ current ])

じゃあ実装します

import { useRef, useEffect, DependencyList } from 'react';

export const useUpdateEffect = (
  effect: () => (void | () => void | undefined), 
  current?: DependencyList
) => {
  // currentは必ず配列を受けるのでprevious.currentがnullかどうかだけで初回か判断してよい
  // const initial = useRef(true);
  const previous = useRef(null);

  useEffect(() => {
   if (previous.current == null) {
     previous.current = current;
     return;
   }

    return effect(...previous.current);
  }, current);
};

これで多分componentDidUpdateとほぼ同等になりました。でも実はこのコードには欠陥があります。
それはeffectの中でクロージャ外の変数を参照すると、古い変数への参照をもちっぱなしになるという、hooksでdepsの指定を忘れるとありがちなやつですね。なのでdepsを追加で指定できるようにしましょう。

previouseffectに渡すので、このdepspreviousと混同してはいけません。実装してみます。

import { useRef, useEffect, DependencyList } from 'react';

export const useUpdateEffect = (
  effect: () => (void | () => void | undefined), 
  current?: DependencyList
  deps?: DependencyList
) => {
  const previous = useRef(null);

  useEffect(() => {
   if (previous.current == null) {
     previous.current = current;
     return;
   }

    return effect(...previous.current);
  }, [...deps, ...current]);
};

はい。これで一見いいように見えますが、depsが入った事により、depsだけの更新でもeffectが走るようになってしまいました… 本当にeffectを走らせるべきかどうかは、currentが変わったかどうかで判断しなければなりません。
またHooksとして振る舞っているので、出来るだけReact標準のHooksに近い挙動にしたいですね。なのでcurrentとpreviousの比較処理はHooksの実装コードからパクって来ましょう。

Reactのコードを読んでいくと、react-reconciler/src/ReactFiberHooks.jsuseEffectの定義があります。

ここからコードをたどっていくとupdateEffectupdateEffectImplareHooksInputsEqual と辿って、ようやくdepsの比較処理が出てきます

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;

そしてこのisが何かというと、同ファイル内で以下のimportをされている関数です。実際のファイルはreact/packages/shared/objectIs.jsにあります。Object.isのポリフィルのようですね。

import is from 'shared/objectIs';

とはいえ、ぱっと書き捨てのhooksにObject.isのポリフィルをつけるのもなんだか気が重いので、雑にfor-inして!==な要素があったら変化したということにするという方針でやります。

あとは、「関数のインターフェースにcurrentってあるけどwatchesの方が何の値入れてほしいのか伝わるじゃん」とか、細かい直しをすると記事の頭で出したコードになります。

import { useRef, useEffect, DependencyList } from 'react';

export const useUpdateEffect = <T extends DependencyList>(
  effect: (...args: T) => void | (() => void | undefined),
  watch: T,
  deps: DependencyList = [],
) => {
  const prevWatch = useRef<T | null>(null);

  useEffect(() => {
    if (prevWatch.current == null) {
      prevWatch.current = watch;
      return;
    }

    // React Hooksの内部の比較処理とObject.isを使ってない以外は同等の更新処理
    for (const idx in watch) {
      if (prevWatch.current[idx] !== watch[idx]) {
        const prev = prevWatch.current;
        prevWatch.current = watch;
        return effect(...prev);
      }
    }
  }, [...watch, ...deps]);
};

はい

いかがでしたか? 弊プロダクトでは割とcomponentDidUpdateを使う機会が多かったので「やるかーやるしかないかー」の気持ちで作りました。(アイテムをアップロードし終わったらモーダル閉じますみたいなやつな)

御プロダクトでも使う機会があったら適当に使ってください。
明日のReact Hooks Advent Calendarは daishi さんの「react-hooks-workerの紹介」です。
(えっ待って、絶対ヤバいやつじゃん… 楽しみ…)

僕はTypeScript Advent Calendar 15日目にも型定義テクみたいな話をするので、そちらもお楽しみに。
最後まで読んでいただいてありがとうございました〜

12
7
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
12
7