ponday.com

React Hooksを説明する

Development
2021-02-07

はじめに

概要

React v16.8 で、マイナーアップデートとは思えないほど影響度の大きな React Hooks という新しいAPIが追加されました。

標準機能として提供されている Hooks は種類も少ないので覚えるだけなら簡単なのですが、その Hooks を使うことによってどういう効果があるのかを把握して使うには少し前提知識が必要な箇所もあります。なのでこの文章では、それらの前提知識を補いつつ Hooks を説明します。

この文章の目的

  • React Hooks 完全に理解した

注意

  • 執筆時点のReact のバージョンは v17.0 です。
  • 概念を説明するための文章なので、細かい挙動や差異については省略ないし同じものとして扱っている箇所があります。

基礎知識

Hooks と Funcion Component

React Hooksの登場により、Reactではほぼすべてのコンポーネントを Funcion Component(関数コンポーネント)として定義できるようになりました。

Hooks 登場以前も Stateless Functional Component という関数形式でコンポーネントを定義する方法はありましたが、名前の通りステートレスであることが前提であり、アプリケーション全体を関数形式で記述できるようなものではありませんでした。

Hooks は関数形式のコンポーネント定義を拡張するもので、状態を持ったり、ライフサイクルイベント(のようなもの)を扱ったりできるようにしてくれました。

Function Component は関数

Function Componentはその名前の通りただの関数です。そのコンポーネントが表示/更新されるとき、内部的にはそのコンポーネント(=関数)が実行されています。

次のようなコンポーネントでは、初期表示のときはまず一度上から順に処理が実行されます。console.log(message); も実行されるので、コンソールにメッセージが表示されます。

const App = () => {
  const message = 'Hello, React Hooks !!';
  
  console.log(message);
  
  return <div>{message}</div>
};

続いて何らかの処理が走ってコンポーネントが更新されることになっても動作は変わりません。console.log(message); も再度実行されることになるので、コンソールにはメッセージが2つ表示されます。

このように、Function Componentは普通の関数の実行と同じように処理されます。

Hooks のルール

関数コンポーネントの中でしか使えない

そもそも関数形式のコンポーネントを拡張する目的のものなので、関数コンポーネントの中での利用を前提にデザインされています。クラス形式のコンポーネントなど、関数コンポーネントの外で実行するとエラーになります。

const [someValue, setSomeValue] = useState(0); // これはNG

const App = () => {
  const [state, setState] = useState(0); // これはOK
  
  ...
}

関数コンポーネントのトップレベルでしか使えない

Hooks は if や for、 try-catch のような制御構文の中では使えません。

// これはNG
if (someFunc()) {
  const [state, setState] = useState(0);
  
  useEffect(() => {
    console.log(state);
  }, []);
}

// こう書く
const [state, setState] = useState(0);

useEffect(() => {
  if (someFunc()) {
    console.log(state);    
  }
}, []);

一定の順序で実行されなければならない

あるコンポーネントの中で、Hooks の実行順序は常に一定である必要があります。

このルールに違反する代表的なパターンとしては早期リターンする場合があります。

const App = () => {
  const [loading, setLoading] = useState(true);
  
  if (loading) {
    return null;
  }

  // これはNG
  // 早期リターンの後に Hooks を置くと実行されたりされなかったりする
  const user = useUserData();
  
  return <div>{`Hello ${user.name}`}</div>;
}

この例では早期リターンするパターンとしないパターンで useUserData という custom hook が呼ばれるか、呼ばれないかが変わってしまいます。このような書き方は hooks のルール上あまり好ましくありません。

実を言えばこのコードはビルドエラーにはなりませんし、実行もできてしまいます。これが少し厄介なところで、変数 loading の値が変わって useUserData が呼ばれる、呼ばれないが変わってしまったときに初めてコンソールにエラーを出力します。このようなエラーを避けるため、hooks の呼び出しはコンポーネントの処理のはじめのほうにまとめるなどするようにしましょう。

基本

基本中の基本と言える関数群で、これなしでは Function Component でステートフルなコンポーネントは定義できないというものです。

useState

関数コンポーネントの中で状態を持つための hook です。『stateの値』と『state更新用の関数』をタプル形式で返します。

const [state, setState] = useState(initialState);

先述した通り Function Component はただの関数なので、通常の変数は実行のたびに初期化されます。しかし useState で定義された state の値が初期化されるのはそのコンポーネントの初回レンダリング時のみで、以降再描画されるときは都度適切な値が返ってきます。

TypeScript で利用する場合、その状態の型を型引数として渡すことができます。

const [counter, setCounter] = useState<number>(0);

ほとんどのケースでは型推論が効くので型引数を指定する必要はありませんが、リテラル型に限定する場合などに時折利用します。

なお、state更新用の関数(setState など)が実行され、かつその値が現在の状態から変わっている時、そのコンポーネントとその子コンポーネントについて更新処理が発生します。

次の例では初回実行時コンソールに一度「App Called」と表示され、3秒後に setText が実行されて text が更新されることでコンポーネントが再実行され、再びコンソールに「App Called」が表示されます。このことからも、state更新用の関数によってコンポーネントの更新がトリガーされていることが確認できます。

const App = () => {
  const [text, setText] = useState('Hello');
  
  console.log('App called');
  
  useEffect(() => {
    setTimeout(() => {
      setText('World');
    }, 3000);
  }, []);
  
  return <div>{text}</div>;
};

useEffect

副作用のあるコードを実行するための hook です。APIの呼び出しやDOMの操作などを行います。

useEffect(() => {
  // Do something...
}, [dependencies]);

第一引数は関数で、コンポーネントが再描画されたときにその関数が実行されます。フレームワークによって呼び方は異なりますが、コンポーネントが描画された時、更新された時のライフサイクルイベントとして利用できます。次の例では、コンポーネントが再描画されるたびにコンソールに 'updated' が出力されます。

const App = () => {
  
  useEffect(() => {
    console.log('updated');
  });
  
  return <div>Hello, World</div>;
}

この関数の実行タイミングはコンポーネントの描画が完了した後になるので、document.querySelector などを使って実DOMにアクセスしたりすることもできます。

第二引数は依存配列と呼ばれるもので、この「配列の中の値が変わった時だけ」関数を実行するように制御することができます。次の例では、依存配列が [a] となっているので a の値が更新されたときにだけ useEffect の関数が実行されます。

const App = () => {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  useEffect(() => {
    console.log('a updated');
  }, [a]);
  
  return (
    <>
      <div>A: {a}</div>
      <div>B: {b}</div>
      <button onClick={() => setA(a+1)}>a + 1</button>
      <button onClick={() => setB(b+1)}>b + 1</button>
    </>
  );
}

依存配列を空配列([])にした場合は、コンポーネントがマウントされたときだけ実行されます。依存配列を省略した場合は再描画のたびに実行されることになりますが、そのような使い方をすることはほとんどありません。

無限ループに注意

useEffect はコンポーネントが描画された後に実行されますが、ここでstateを更新すると当然再度コンポーネントの更新がトリガーされます。すると再描画の完了後に再び useEffect が実行されることになるので、依存配列を適切に設定していないと簡単に無限ループを引き起こします。

最もシンプルな無限ループの例は次のようなコードです。

const App = () => {
  const [state, setState] = useState(0);
  
  useEffect(() => {
    setState(state + 1);
  }, [state]);
  
  return <div>{state}</div>;
}

この例の場合、useEffect の依存配列から state を削除するか、この useEffect のブロック内で setState を行わないようにする必要があります。

逆に言えば React の仕組みに依存して発生し得る無限ループのパターンはほぼこれだけなので、万が一誤って無限ループになってしまった場合はこのパターンを疑いましょう。

メモ化

プログラムの高速化のテクニックとして、メモ化というものがあります。

メモ化はキャッシュを用いた手法の一つで、何度も繰り返し呼び出される関数などに対して用いられます。ある引数に対する関数の戻り値をキャッシュしておいて、後で同じ引数で関数が呼び出されたときに関数の処理をスキップし、キャッシュしておいた結果を返すようにすることで高速化を図る手法です。

const args = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10];

const factrical = (n: number): number => {
  if (n === 0) {
    return 1;
  } else {
    return n * factrical(n - 1);
  }
}

const cache: { [index: number]: number } = {};
const memoizedFactrical = (n: number): number => {
  if (n === 0) {
    return 1;
  } else if (n in cache) {
    // キャッシュに同じ引数のときの結果があればそれを返す(再帰呼出しの停止)
    return n * cache[n];
  } else {
    const v = memoizedFactrical(n - 1);
    cache[n] = v;
    return n * v;
  }
}

const start1 = new Date().getTime();
const res1 = args.map(n => factrical(n));
const time1 = new Date().getTime() - start1;

const start2 = new Date().getTime();
const res2 = args.map(n => memoizedFactrical(n));
const time2 =  new Date().getTime() - start2;

console.log('factrical', time1, res1);
console.log('memoized ', time2, res2);

このメモ化の概念を Function Component の世界に取り込んだのがこれから紹介する useCallback と useMemo です。

Function Component もコンポーネントの更新のたびに多くの処理を行います。関数呼び出しが記述されていれば毎回その関数を呼び出しますし、 if 文による分岐などがあればその条件分岐の評価も行います。関数やオブジェクトの生成が記述されていれば当然それらも毎回生成し直します。

一つ一つの処理は小さいですが、一つのアプリケーションともなれば処理の量は膨大になり、オーバーヘッドも無視できないものになってきます。そこで、これらにメモ化の概念を取り入れることで無駄な処理を省略したり、余計なレンダリングを実行させないようにすることでアプリケーション全体のパフォーマンスを最適化します。

useCallback

useCallback は関数をメモ化します。コンポーネントの状態に依存する関数や、イベントハンドラーの定義などで利用します。

const callback = useCallback(callbackFn, [dependencies]);

「使わなくても開発できてしまう」hook の代表的なものがこの useCallbackでしょう。あくまでパフォーマンスの最適化のための hook なので、コンポーネントの中で関数を定義するときに useCallback を使っても使わなくても、見た目上の動作には影響はありません。しかしわざわざ標準機能として提供されているということは、きちんとした目的があるということです。

useCallback は第二引数である依存配列の値が変化した場合にだけ、第一引数で渡されたコールバック関数を再生成します。

const App = () => {
  const [counter, setCounter] = useState(0);
  
  const increment = useCallback(() => {
    setCounter(counter + 1);
  }, [counter]);
  
  return (
    <div>
      <div>{counter}</div>
      <button onClick={increment}>+1</button>
    </div>
  );
}

当然、第二引数が空配列であればその関数は初回表示以降同じ関数オブジェクトが使い回されることになります。

関数オブジェクトの生成もややコストが高い処理なのでそれをスキップできるという点で有効ですが、依存配列が変化していないときに「関数オブジェクトが使いまわされる」というのも後述の React.memo などでは有効に機能します。

useMemo

useMemo は引数に渡された関数の戻り値をメモ化します。コンポーネント内のローカル変数のうち、『条件分岐や関数呼び出しなどの処理を伴って値が決まる変数』や、object や配列のような変数を定義するときに使います。

const value = useMemo(callbackFn, [dependencies]);

見ての通り、useMemo のインタフェースは useCallbackと全く同じです。useCallback は引数に渡された関数自体をメモ化するのに対して、useMemo は関数自体ではなく、その関数の処理結果を返すのが異なるポイントです。

次の例では counter1, counter2 という2つの状態と、double という counter2 に依存する値が存在しています。double は重い処理の代わりに大量のループをさせています。

const App = () => {
  const [counter1, setCounter1] = useState(0);
  const [counter2, setCounter2] = useState(0);
  
  const double = useMemo(() => {
    // 重い処理の代わり
    for (let i = 0; i < 100000000; i++) {}

    return counter2 * counter2;
  }, [counter2]);
  
  const increment1 = useCallback(() => {
    setCounter1(counter1 + 1);
  }, [counter1]);
  
  const increment2 = useCallback(() => {
    setCounter2(counter2 + 1);
  }, [counter2]);

  return (
    <div>
      <div>{counter1}</div>
      <button onClick={increment1}>counter1 + 1</button>
      <div>{counter2}, {double}</div>
      <button onClick={increment2}>counter2 + 1</button>
    </div>
  );
}

useMemoを使わない場合、このdoubleの計算処理は毎回実行されることになります。

このコンポーネントだけで言えば、counter1, counter2 のどちらかが変化した場合です。しかしこの場合、double の計算に counter1 は関係ないのに counter1 を更新した場合にも double の計算処理が完了するまで画面の更新がブロックされてしまいます。そこで、doubleの計算処理を useMemo でラップし、依存している counter2 が変化したときだけ計算処理を実行するようにすることで余計な待ち時間などを発生しないようにできます。

ちなみに、 useMemo でも『関数を返す関数』を引数に渡せば関数オブジェクトをメモ化することができます。

// これは useCallback(() => console.log('...'), []) と同じ
const callback = useMemo(() => () => console.log('Hello, World'), []);

そういう意味では、 useCallback は機能を関数に限定した useMemo であると言えます。

(参考) React.memo との組み合わせによるパフォーマンス最適化

useMemo, useCallback は関数オブジェクトの生成や計算処理など、コンポーネント内部の処理をメモ化して最適化するものでしたが、React が提供している memo 関数と組み合わせることで更に最適化を図ることもできます。

まずは React.memo 自体の動作を確認しましょう。React.memo はここで紹介した useMemo, useCallback と同様にメモ化を実現する機能で、コンポーネント自身がメモ化の対象です。

useMemo, useCallback の場合は第2引数の依存配列の値によって関数や計算処理の再計算が必要かどうかを判断していました。一方、React.memo ではコンポーネントに渡されたプロパティの値を元にコンポーネントの再描画が必要かどうかを判断します。

それではReact.memo でラップされたコンポーネントの再描画処理がどのように行われるかを確認してみましょう。例えば、次のようなコードがあったとします。

interface Props {
  count: number;
}

export const Memoized: React.FC<Props> = React.memo(({ count }) => {
  console.log("rendered: 1");
  return <div>Memoized: {count}</div>;
});

export const NotMemoized: React.FC<Props> = ({ count }) => {
  console.log("rendered: 2");
  return <div>Not Memoized: {count}</div>;
};

export const App = () => {
  // Memoized, NotMemoized に渡されるstate
  const [count1, setCount1] = useState(0);
  // Memoized, NotMemoized には渡されないstate
  const [count2, setCount2] = useState(0);

  const increment1 = useCallback(() => {
    setCount1(count1 + 1);
  }, [count1]);

  const increment2 = useCallback(() => {
    setCount2(count2 + 1);
  }, [count2]);

  return (
    <div style={{ padding: "16px" }}>
      <div>count1: {count1}, count2: {count2}</div>
      <button onClick={increment1}>count1 + 1</button>
      <button onClick={increment2}>count2 + 1</button>

      <Memoized count={count1} />
      <NotMemoized count={count1} />
    </div>
  );
};

シンプルに定義された NotMemoized と、React.memo でラップされた Memoized コンポーネントが定義されています。React.memo でラップされている以外はコンポーネントの中身も渡されている props も同じです。Appコンポーネントでは Memoized, NotMemoized に渡されている count1 と、渡されない count2 の2つの state を持ちます。

count1 + 1 のボタンがクリックされたとき、setCount2 が実行されるので App コンポーネントが再評価されます。このとき、子コンポーネントである Memoized, NotMemoized も再評価されることになります。したがって、開発者ツールのコンソールには次のように表示されます。

rendered: 1
rendered: 2

コンポーネントの関数が再実行されている= console.log が実行されるということなのでこの結果はわかりやすいでしょう。

では、count2 + 1 のボタンがクリックされたときはどうなるでしょうか。

rendered: 2

NotMemoized の console.log だけが実行されています。count2 は Memoized にも NotMemoized にも渡されていないので一見どちらの console.log も実行されない、と予想された方もいるかもしれません。

ここで重要なのは、親コンポーネントが再評価されるとき、子コンポーネントは全て再評価されるということです。つまり、count2 + 1 がクリックされ、状態が更新されたとき、 Memoized も NotMemoized も再評価されています。しかし Memoized コンポーネントは React.memo でラップされている = props の値をキーにメモ化されており、再評価されたときに props の値が変わっていない = 前回の実行結果が使いまわせるということで Memoized コンポーネントは再実行されません。再実行されないので当然 console.log も実行されない、ということになります。

次に、この React.memo と useMemo, useCallback がどのように動作に影響してくるのかを確認しましょう。次のようなコードを用意しました。

interface Props {
  id: number;
  onClick?: () => void;
}

export const Button: React.FC<Props> = React.memo(
  ({ children, id, onClick }) => {
    console.log(`rendered: ${id}`);
    return <button onClick={onClick}>{children}</button>;
  }
);

export const App = () => {
  const [count, setCount] = useState(0);
  const [updated, setUpdated] = useState(new Date());

  const onClick1 = () => {
    setCount(count + 1);
  };

  const onClick2 = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  // stateを更新して強制的にAppコンポーネントを再実行させる
  const forceUpdate = useCallback(() => {
    setUpdated(new Date());
  }, []);

  return (
    <div style={{ padding: "16px" }}>
      <div>
        count: {count} (updated: {updated.getTime()})
      </div>
      <button onClick={forceUpdate}>force update</button>

      <Button id={1} onClick={onClick1}>
        increment 1
      </Button>
      <Button id={2} onClick={onClick2}>
        increment 2
      </Button>
    </div>
  );
};

onClick1 はメモ化していないもの、 onClick2 は useCallback でメモ化したものです。Button コンポーネントは自体は React.memo でラップされています。

まずは Button コンポーネントがクリックされたときを確認します。onClick1 でも onClick2 でも setCount が実行されるので App コンポーネントが再実行されます。するとメモ化されていない onClick1 は再生成されますし、メモ化されている onClick2 も count の値が変化しているので再生成されます。すると、2つの Button コンポーネントはどちらも props のうち onClick が変化するので、再実行されます。したがって出力結果は次のようになります。

rendered: 1
rendered: 2

force update がクリックされたときは次のような結果になります。

rendered: 1

force update がクリックされた場合 count は更新されていませんが、メモ化されていない onClick1 は再生成されており、メモ化されている onClick2 は再生成されていないというのがポイントです。

前提として、onClick1, onClick2のような関数オブジェクト変数の中身は関数オブジェクトそのものではありません。関数オブジェクトの実態はメモリ上に保持され、変数にはそのメモリ上のアドレスだけが格納されています(= ポインタ)。これは関数だけではなく、オブジェクトや配列などでも同様です。

React.memo は props の値が変化したコンポーネントの関数を再実行しますが、この「値が変化した」というチェックを行うとき、これらの変数に関しては同じアドレスかどうかで判断しています。実態の値が同じかどうかは関係ありません。

onClick1 のように、単純に const someFunc = () => {} という構文は関数オブジェクトの生成を伴うので、実行されるたびに変数の値(= 関数オブジェクトのアドレス)が変わってしまいます。一方で onClick2 のように useCallback でメモ化している場合、依存配列の値(= count)が同じときは生成済のオブジェクトを使い回すのでアドレスの値も変化しません。

これを踏まえてサンプルの動作を確認すると、force update がクリックされて App コンポーネントが再実行されたとき、 onClick1 はメモ化されていないのでアドレスが変化します。すると onClick1 を受け取っている Button コンポーネントは props が変化したと判断するので、関数の動きは何も変わっていないのにコンポーネントを再実行してしまいます。一方 onClick2 はメモ化されており、force update のときは生成済の onClick2 が使い回されます。すると onClick2 を受け取っている Button コンポーネントも props が変化していないと判断するので、コンポーネントを再実行しない、ということになります。

コンポーネントの props にはイベントハンドラなど、参照値を渡すシーンがかなり多いので、せっかく React.memo を使っていても useMemo や useCallback を忘れてしまうと余計な再描画が走ってしまうことがある、というのを理解しておきましょう。

その他

ここで取り上げる Hooks はこれまでに説明したものに比べて必要になる場面は少ないです。まれに必要になるので名前は把握しておきましょう。

今後追加するかもしれない項目

  • useContext
  • useReducer

useRef

コンポーネント内で値を保持します。

const ref = useRef(initialValue);
// もしくは
const ref = useRef<T>(initialValue);

主な用途は DOM 要素への参照を保持することです(例:input 要素に focus を当てる、DOM要素のサイズを取得する)

次の例では input 要素の参照を保持して、ボタンがクリックされたときにその input 要素にフォーカスを当てています。

export const App = () => {
  const ref = useRef<HTMLInputElement>(null);

  const onClick = useCallback(() => {
    ref.current?.focus();
  }, [ref.current]);

  return (
    <div>
      <input ref={ref} />
      <button onClick={onClick}>focus input</button>
    </div>
  );
};

React では数少ない DOM を直接操作できる API ですが、仮想DOMとの整合性を崩さないよう注意が必要です。基本的には参照するためだけに利用して、DOMを更新したり削除したりしないようにしましょう。

もう一つの用途は useState のようなコンポーネント内の(再実行時にも値が維持される)変数です。 値を更新してもコンポーネントが再描画されないのが useState との大きな違いで、変数としては保持したいけど再描画は必要ない、という場合に利用します。この用途での利用は決して頻繁ではありませんが、時折必要になります。

#React

Share

tweetブックマークshare

Profile

アバター

pondayEngineer

フロントエンドもバックエンドもまんべんなく。 TypeScriptが好き。

  • Twitter
  • GitHub
Copyright © 2019 @ponday All Rights Reserved.