React Hooks入門

56
22

ReactでHooks APIが登場したのは2019年2月。現在では当たり前のように使われているHooksですが、みなさんは正しく使いこなせているでしょうか? 本記事ではHooks APIの基本的な使い方から、注意点まで説明します。

useStateとは

HooksはReactバージョン16.8で追加された新機能です。stateやライフサイクルといったReactの目玉機能を、クラスコンポーネントでなくとも使えるようになります。次のコードは、ボタンをクリックすると数値が増えるカウンターを作成するコンポーネントです。数値はReactのstate機能を使って管理されています。

import React, { useState } from "react";

export const CounterComponent = () => {
  // state を追加
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>{count}</p>
      {/* クリックすると count が増える関数 */}
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        クリック
      </button>
    </div>
  );
};

上記コードのuseState()という関数がHooksです。この関数を使って、Reactのstate機能を関数コンポーネントでも使えるようにしています。

useState()を実行すると、ステートと、ステートを更新するための関数をペアにして得られます。変数の名前は任意ですが、更新するための関数は「set○○」といった形式にする場合が多いです。ステートには数値や文字列、オブジェクトなど、任意の値を初期値として渡せます。

useEffectとは

useEffectは、クラスコンポーネントのライフサイクルメソッドであるcomponentDidMountcomponentDidUpdatecomponentWillUnmountの機能を包括して持っている多機能なフックです。

useEffect には引数を2つ指定する必要があります。第1引数には実行したい関数を指定します。第2引数の配列に渡した値が変更されるたびに第1引数の関数を実行します。ここで指定した関数は「副作用関数」と呼ばれ、コンポーネントのレンダリング時・更新時に実行されます。

実行タイミングを第2引数で制御する

useEffectの第2引数に空配列を渡すと、コンポーネントのマウント時およびアンマウント時にのみ実行されます。これはライフサイクルメソッドのcomponentDidMountcomponentWillUnmountに相当します。

▼ コンポーネントのマウント時およびアンマウント時にconsole.log()を実行する

import React, { useEffect } from "react";
export const AlwaysMountComponent = () => {
  // コンポーネントのマウント時およびアンマウント時にconsole.log() を呼び出します。
  useEffect(() => {
    console.log("useEffect: コンポーネントが更新されました");
  }, []);

  return <div>useEffectの第2引数は空配列を渡せます</div>;
};

※React 18以降のStrict Modeでは、マウント時とアンマウント時のuseEffectは二度実行されます。学びはじめで不安な方は、ReactのStrict Modeを無効にすると学びやすいでしょう。

useEffectのクリーンアップ

useEffectには戻り値に関数を指定できます。ここで指定した処理を「クリーンアップ関数」と呼び、コンポーネントアンマウント時(componentWillUnmountに相当します)で実行されます。次のコードは、setInterval()を使った1秒おきに数値が増えるコンポーネントです。コンポーネントの破棄時にclearInterval()を実行し、タイマーを解除しています。

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

export const UseEffectComponent = () => {
  const [isView, setIsView] = useState(true);
  const handleToggleView = () => {
    setIsView(!isView);
  };
  return (
    <div>
      <div className="box">{isView && <CountComponent />}</div>
      <button className="button" onClick={handleToggleView}>
        数値の表示を切り替える
      </button>
    </div>
  );
};

const CountComponent = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const countUp = setInterval(() => {
      setCount((count) => count + 1);
      console.log("カウントが1アップしました");
    }, 1000);
    // 戻り値の指定。
    // ここでは、コンポーネントがアンマウントされたときにタイマーを解除しています
    return () => {
      console.log("コンポーネントがアンマウントされました");
      clearInterval(countUp);
    };
  }, []);
  return <p className="num">{count}</p>;
};

「数値の表示を切り替える」ボタンを押してCountComponentを非表示にすると、タイマーが解除されていることがわかります。

クリーンアップ関数でclearInterval()を実行しないと、要素が非表示になっても内部的にsetInterval()の処理が実行されてしまうため、パフォーマンスに影響を与えてしまいます。

useCallbackとは

Reactには、メモ化という仕組みが存在します。これは関数の実行時に前回実行された関数と入力された引数を比較し、変更がなければ前回の実行によって得られた値をそのまま返すというものです。Reactはこれにより、関数の実行の効率化を行っています。

useCallbackはメモ化したコールバック関数を返すHooksです。Reactでコールバック関数を利用する際に、通常通り書いてしまうとコンポーネントが更新されるたびに新しい関数インスタンスが発行されてしまい、パフォーマンスに悪影響となります。正しい用途で使用すれば、コンポーネントの不要な再レンダリングを防げます。

また、再レンダリングを避けるには、子コンポーネントもメモ化してあげる必要があります。Reactのメモ化は、コールバック関数だけでなくコンポーネントにも適用可能です。コンポーネント全体をmemo()関数で囲ってあげると、そのコンポーネントをメモ化できます。次のコードは、ChildComponent.jsmemo()で囲ったものです。

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

// memoでラップ
const ChildComponent = memo(({ name, handleClick }) => {
  console.log(`子コンポーネント「${name}」が再レンダリングされました`);
  return <button onClick={handleClick}>{name}を+1</button>;
});

/**
 * 数値を表示するコンポーネントです。
 * 子コンポーネントに、クリックすると
 * 数値が増えるボタンを持っています。
 */
// Counterコンポーネント(親)
export const UseCallbackComponent = () => {
  // ふたつの state を用意
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // count1 のセット用関数
  // useCallbackで関数をラップし、依存配列には関数内で利用している count1 を入れます。
  const handleClick1 = useCallback(() => {
    setCount1(count1 + 1);
  }, [count1]);

  // count2 のセット用関数
  // useCallbackで関数をラップし、依存配列には関数内で利用している count2 を入れます。
  const handleClick2 = useCallback(() => {
    setCount2(count2 + 1);
  }, [count2]);

  //子コンポーネントを呼び出す
  return (
    <div>
      <div>
        {count1} , {count2}
      </div>
      <div className="buttonGroup">
        <ChildComponent name="カウンタ1" handleClick={handleClick1} />
        <ChildComponent name="カウンタ2" handleClick={handleClick2} />
      </div>
    </div>
  );
};

上記のコードを実行すると、それぞれのボタンを押すと、対応したコンポーネントのconsole.log()のみが実行されることがわかります。

useRefでDOMを参照する

useRef はReactコンポーネントの要素に対して参照(reference)を作成できます。JavaScriptのDOM操作でいうところのdocument.querySelectorのような使い方ができます。おもにReact側でDOMに対する参照を持つために使われることが多いです。useRefをDOM要素への参照として使う場合、マウントされた時に参照が割り当てられ、マウントが解除されるとnullとなります。そのため、useRefの初期値にはマウントされていない時の値としてnullを定義します。

import React, { useRef } from "react";
export const StateComponent = () => {
  // 🔽 h1要素への参照を作成。
  // 初期値として、マウントされていない時の値としてnullを定義
  const refContainer = useRef(null);
  return <h1 ref={refContainer}>参照を確認</h1>;
};

参照したいReactコンポーネントの要素には、ref属性を付与する必要があります。参照した値はcurrentプロパティーで確認できます。以下は、ボタンをクリックするとインプット要素にフォーカスが当たるコードです。

▼インプット要素にフォーカスさせるコード

import React, { useRef } from "react";

export const UseRefComponent = () => {
  const inputEl = useRef(null);
  const handleClick = () => {
    // current の要素を参照
    inputEl.current.focus();
  };
  return (
    <div>
      <input className="input" ref={inputEl} type="text" />
      <button className="button" onClick={handleClick}>
        input要素にフォーカスする
      </button>
    </div>
  );
};

useRefで任意の値を保持する

useRefにはJSX要素の参照だけでなく、任意の値を保持できます。useStateとの違いとして、useRefで保持している値は、変更されてもReactコンポーネントが再レンダリングされないという特徴があります。「コンポーネントの値を変えたいけど、再レンダリングは行いたくない」というケースで活用すると良いでしょう。次のコードを実行すると、内部の値は変更されていても<p>要素の表示は変わっていないことがわかります。

import React, { useEffect, useRef } from "react";

const UseRefValueComponent = () => {
  // 🔽 useRefに任意の値を保持させる
  const count = useRef(0);
  useEffect(() => {
    // 🔽 レンダリングされたときにuseEffectでconsole.log()を実行
    console.log("レンダリングが実行されました");
  });
  // 🔽 useRefで定義した数値を増やすイベント
  const handleAddCount = () => {
    count.current += 1;
    console.log(`count.currentの値は現在${count.current}です。`);
  };
  return (
    <>
      <button onClick={handleAddCount}>数値を1増やす</button>
      {/* 🔽 useRefの現在値を表示する */}
      <p>{count.current}</p>
    </>
  );
};

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

ここからはHooksの注意点を説明します。

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のダウンロードが完了するまで動作しないため、注意しましょう。

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で値を変更した時とは違い、画面の再レンダリングが行われません。

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

Hooksのお約束

Reactは、コンポーネントで呼び出されるHooksは必ず同じ順番・同じ回数で呼び出されることをルールとしており、場合によってHooksの順番や実行回数が変わることを禁止しています。そのため、「このケースのときはHooksを実行したくない」といった書き方はできません。

▼ 以下のような書き方はできない

import React, { useState } from "react";

export const StateComponent = () => {
  const [value, setValue] = useState("");
  setValue("hoge");
  if (value === "hoge") {
    const [value2, setValue2] = useState("huga");
  } else {
    const [value2, setValue2] = useState("foo");
  }
  // ...省略...
};

状況によって処理を分岐させたいときは、Hooks関数の内部で行うようにしましょう。

意外と便利、useDebugValue

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

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

▼開発者ツールで確認した図 図版2:開発者ツールでuseDebugValueの値を取得

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

まとめ

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

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

編集部

ICS MEDIAは株式会社ICSが運営するオウンドメディアです。ICSはインタラクションデザイン専門のプロダクション。最先端のウェブテクノロジーを駆使し、オンスクリーンメディアの表現分野で活動しています。

この担当の記事一覧