エーッ！HooksでcomponentDidUpdate出来ないんですか！！？

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

## TL;DR

使って、どうぞ。

```ts: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:使い方.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`を作ればいいという感じですね。じゃあ作ってみましょう。

```ts
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`に合わせて、直前の値を使えるようにしてみましょう。

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

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

じゃあ実装します

```ts
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`を追加で指定できるようにしましょう。

`previous`は`effect`に渡すので、この`deps`は`previous`と混同してはいけません。実装してみます。

```ts
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.js](https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberHooks.js#L1383)に`useEffect`の定義があります。

ここからコードをたどっていくと[updateEffect](https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberHooks.js#L930) → [updateEffectImpl](https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberHooks.js#L890) → [areHooksInputsEqual](https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberHooks.js#L320) と辿って、ようやく`deps`の比較処理が出てきます

```js
  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](https://github.com/facebook/react/blob/master/packages/shared/objectIs.js#L14)にあります。`Object.is`のポリフィルのようですね。

```js
import is from 'shared/objectIs';
```

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

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

```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]);
};
```

## はい

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

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

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