正しく使えてますか? Hooks APIのおさらい(初級編)

16
38

ReactでHooks APIが登場したのは2019年2月。現在では当たり前のように使われているHooksですが、みなさんは正しく使いこなせているでしょうか? Hooksの基本的な使い方を振り返り、正しく使えているかの参考にしていただければと思います。

useStateで更新したのにレンダリングされない?

Reactコンポーネントの状態管理を行うのにほぼ必須といえるState。useStateはそんなステートを関数型で使える便利なHooksです。実は、useStateは注意しなくてはいけない点があります。たとえば以下のようなTODOリストを実装したとします。

import React, { useCallback, useState } from "react";

/**
 * TODOリストを作成するコンポーネントです。
 */
export const TodoList = () => {
  const [lists, setLists] = useState([]);
  const [value, setValue] = useState("");

  // ボタンをクリックしたとき、TODOリストの配列をひとつ増やすイベントハンドラー
  const handleClick = useCallback(() => {
    const id = lists.length;
    // pushメソッドで破壊的に配列を変更
    lists.push({ id, text: value });
    setValue("");
  }, [lists, value]);

  // inputの値をvalueにセットするイベントハンドラー
  const handleChange = useCallback(
    (e) => {
      setValue(e.target.value);
    },
    [setValue]
  );
  return (
    <div>
      <h1>TODO LIST</h1>
      <button onClick={handleClick}>タスクを追加</button>
      <input value={value} onChange={handleChange} />
      {lists.map((list) => (
        <div key={list.id}>
          list.text
        </div>
      ))}
    </div>
  );
};

useStateは既存のStateと状態更新関数で渡ってきた新しい値を比較し、違いがあれば更新を行います。ですが、現在のstateの配列にsplice()push()等の破壊的なメソッドで変更を加えてから渡し直しても画面の再描画が起こりません。

現在の状態を表す配列を直接変更するのではなく、別の配列を新たに生成してuseStateの更新関数に渡すようにすれば正しく更新されます。

  // ボタンをクリックしたとき、TODOリストの配列をひとつ増やすイベントハンドラー
  const handleClick = useCallback(() => {
    const id = lists.length;
    // 新しく配列を作成して、更新関数に渡す
    const newList = [...lists, { id, text: value, isComplete: false }];
    setLists(newList);
    setValue("");
  }, [lists, value]);

正しく使えてる?useEffectの落とし穴

useEffect 以下は一見問題なさそうに見えますが、非常に危険なコードです。

import React, { useEffect, useState } from 'react';

export const Counter => {
  const [count, setCount] = useState(0);
  // ステータスが変更されるたびに関数を実行させる。
  useEffect(() => {
    setCount(count + 1);
  }, [count]);
  return <div>{count}</div>;
}

useEffectのコールバック関数でステートを変更しているにもかかわらず、第二引数にステートを渡してしまうと処理が無限ループしてしまいます。

このとき、コンソールには以下のような警告が発生しています。

Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn’t have a dependency array, or one of the dependencies changes on every render.

依存関係にある値が更新されるごとにuseEffectが呼び出されています。上記の関数ではcountを第二引数にセットしていますが、この値はuseEffectの関数が実行されるたびに更新されます。そのため、

  • useEffectの処理を実行
  • countの数値を更新
  • countが更新されたのでuseEffectが再度実行

…という無限ループに陥ってしまうのです。対策として、useEffectの第二引数を空にします。第二引数を空にすると、マウント・アンマウント時にしか呼ばれなくなります。

import React, { useEffect, useState } from 'react';

export const Counter => {
  const [count, setCount] = useState(0);
  // 第二引数を空にする
  useEffect(() => {
    setCount(count + 1);
  }, []);
  return <div>{count}</div>;
}

useLayoutEffectは使った方がいい?

現在提供されているHooksには、useEffectuseLayoutEffectという似たようなものがあります。どのようなときに使い分ければよいのでしょうか?

useLayoutEffectuseEffectとほぼ同じ動作を行いますが、副作用として設定しているコールバック関数が同期的に呼び出されるところにおおきな違いがあります。useEffectはDOMの変更を待たずに非同期で呼び出されるために、コールバック関数の処理が完了する前のDOMがユーザーに一瞬見えてしまうことがあります。それに対しuseLayoutEffectはDOMの変更を待ってから同期的に処理が行われるため、画面に不整合が生じません。

しかし残念ながらデメリットもあります。同期的に実行されるということは、画面描画完了までの時間が長くなってしまうということです。結果的にどうしてもユーザーへのページ表示が遅くなってしまい、ユーザーの体験を損なってしまいます。どうしても都合が悪い時など以外は useEffectの方を使ったほうが無難でしょう。

公式ドキュメントでも、基本的にuseEffectの方を使うことが推奨されています

また、プロジェクトでサーバーサイドレンダリングを使用している場合は、useEffectおよびuseLayoutEffectのどちらもJavaScriptのダウンロードが完了するまで動作しないため、注意しましょう。

useMemoとuseCallbackの違い

useMemouseCallbackは両方とも実行する処理を第一引数に、監視対象となる値の配列を第二引数に持ちます。

useMemoは一度計算した結果を変数に保持してくれる「メモ化」を行います。最初のレンダリングで一度作業を行い、第二引数に渡した依存する値が変化しない限りはキャッシュされたものを返します。それに対してuseCallbackは、第一引数に渡しているコールバック関数をメモ化し、不要な再レンダリングを防いでくれます。

useMemoは大きなデータ処理に、useCallbackは関数に依存関係を追加して不要なレンダリングを抑制したいときに使用するとよいでしょう。また、useCallbackでメモ化しているコールバック関数は、メモ化しているコンポーネントに渡して利用することで正しく機能します。作成したコンポーネント自身で利用しても意味がないので気をつけましょう。

// NG: メモ化したコンポーネントに渡さず、そのまま利用
const Component = () => {
  const handleClick = useCallback(() => { ... }, []);
  return (
    <button onClick={handleClick}>Click!</button>
  );
};

// OK: メモ化したコンポーネントに渡して利用
const Component = () => {
  const handleClick = useCallback(() => { ... }, []);
  return (
    <ChildButtonComponent handleClick={handleClick} />
  );
};

const ChildButtonComponent = React.memo(({ handleClick }) => {
  return (
    <button onClick={handleClick}>Click!</button>
  );
});

useRefはDOMアクセスに使うだけ?

ReactにおいてDOMの制御を行いたい時はuseRefのHooksを使ってref属性を取得し、DOMにアクセスすることが多いです。

const TextInput = () => {
  // useRefを使ってrefオブジェクトをReactに渡すと、
  // .currentプロパティの中に指定された値を格納します。
  const inputRef = useRef(null);
  return (
    {/* 以下のようにref属性にuseRefで指定した値を渡すと、
    DOMに変更があるたびに .current プロパティをDOMノードに設定します。 */}
    <input ref={inputEl} type="text" />
  );
};

ですが、useRefはより便利に使う方法があります。 useRefで作成される.currentプロパティは汎用的なコンテナーとして用意されているもので、書き換え可能かつどのような値でも保持できます。これはクラスでインスタンス変数を使うのと同様の利用ができます。たとえば以下のコードのように、イベントハンドラーの中でインターバルのタイマーをクリアしたいときなどに活用できます。

const Timer = () => {
  // タイマーID用ののrefオブジェクトを作成
  const intervalRef = useRef();

  useEffect(() => {
    const id = setInterval(() => {
      // setIntervalの処理
    });
    // 発行されたタイマーIDをrefオブジェクトに格納
    intervalRef.current = id;
  });
  const handleCancel = () => {
    // タイマーのインターバルをリセット
    clearInterval(intervalRef.current);
  };
  return (
    <button onClick={handleCancel}>タイマーストップ</button>
  );
};

また、useRefは中身が変更されても、その変更をコンポーネント側に通知することはありません。つまり、useStateで値を変更した時とは違い、画面の再レンダリングが行われません。

無駄なレンダリングを避けたい時や、画面遷移によるコンポーネントの再レンダリング時において、値を保持したいときなどに活用するとよいでしょう。

意外と便利、useDebugValue

その名の通り、useDebugValueはデバッグに使えるフックです。Reactの開発を進めていくと、基本のHooksを組み合わせた独自のカスタムフックを作成することも多いです。useDebugValueは、カスタムフックのデバッグ情報をReactの開発者ツール用拡張機能(React Dev Tools)に表示させることができます。useDebugValueはどのカスタムフックから呼び出されたのか検知し、対応するカスタムフックの横に指定したデバッグ情報を表示します。

React用拡張機能で見ると下の画像のように表示されています。

▼開発者ツールで確認した図

useDebugValueフックで指定した値がカスタムフックのデバッグ情報として表示されていることがわかります。

まとめ

HooksはReactでのアプリケーション開発において、頻繁に使用するようになりました。ですが、正しく使用しないとパフォーマンスが落ちてしまったり無限ループバグの温床となってしまう可能性もあります。予期せぬ不具合を避けるため、プロジェクトでHooksを使う時はしっかりとルールを定めて使用するように決めておくのもひとつの手です。

公式でeslint-plugin-react-hooksという、Hooksをルールどおり正しく使えているかどうかをチェックするプラグインを提供してくれていますので、これを活用するとよいでしょう。