【React】useEffectについてもっと詳しく(useMemo, useCallbackも)

useEffectについて、こちらの書籍より学ばせていただいたことをまとめています。

書籍の説明

書籍名: Reactハンズオンラーニング 第2版 ―Webアプリケーション開発のベストプラクティス
著者: Alex Banks 著
出版社: オライリージャパン 発行
発売日: 2021/8/6

戻り値の利用

useEffectは戻り値returnを返すこともできます。

この戻り値に、別の関数を返した場合、コンポーネントがツリーからアンマウントされたときに関数が呼び出されます。

useEffect(() => {
  welcomeChime.play();
  return () => goodbyeChime.play();
}, []);

つまり、返り値にクリーンアップ処理を記述して、コンポーネントの削除タイミングで処理が実行されるようにできるということです。

この例では、コンポーネントがアンマウントされたときに一度だけ、goodbyeChimeがplayされる。

どんなときに使うのか?

ニュースの見出しを表示するコンポーネントにおいて、

初回描画時にニュースフィードにサブスクライブし、

削除時にアンサブスクライブする、といったときに使うことができる。

useEffect(() => {
  newsFeed.subscribe(addPost)
  return () => newsFeed.unsubscribe(addPost);
}, []);

useEffectのカスタムフック化

上記のuseEffectの処理をカスタムフックとしてみます。

ニュースの新しい投稿があるたびに再描画され、初回描画時と削除時に処理を行うhookとしてuseJazzNewsという名前で作ります。

import { useState, useEffect } from "react";

export const useJazzNews = () => {
  const [posts, setPosts] = useState([]);
  const addPost = (post) => setPosts((allPosts) => [post, ...allPosts]);

  useEffect(() => {
    newsFeed.subscribe(addPost);
    return () => newsFeed.unsubscribe(addPost);
  }, []);

  useEffect(() => {
    welcomeChime.play();
    return () => goodbyeChime.play();
  }, []);

  return posts;
};

このカスタムフックを利用する側

上記で作成したカスタムフックで、初回描画時と削除時の処理が行われるため、利用する側では、データを描画するだけでよくなります。

import { useJazzNews } from "./useJazzNews";

export function NewsFeed({ url }) {
  const posts = useJazzNews();

  return (
    <>
      <h1>{posts.length} articles</h1>
      {posts.map((post) => (
        <Post key={post.id} {...post} />
      ))}
    </>
  );
}

依存配列の同一性チェック

依存配列には、文字列を指定することができ、純粋に値が同じかどうかで同一性が評価されています。

ところが、これには問題があります。

配列やオブジェクトでは評価が行えないのです。

その理由を、文字列と配列を例に説明します。

文字列の場合

const a = "green"
const b = "green"

a === b

> true

これは、真偽値や数値も同様の結果となる。

当然結果はtrueになります。

配列の場合

const a = [1, 2, 3]
const b = [1, 2, 3]

a === b

> false

オブジェクトや関数もこれと同様の結果となります。

見た目は全く同じなのに、結果はfalseとなります。

なぜなら、JavaScriptの配列は、同一のインスタンスかどうかで評価されるからです。

そのため、以下の場合だとtrueとなります。

const a = b = [1,2,3]

a === b

> true

配列、オブジェクト、関数は同一のインスタンスが存在することは無いということです。

依存配列に指定した変数の値が変わっていないのに再描画される

上記の前提知識を踏まえ、依存配列の値を使った結果を見てみます。

function App() {

  const words = ["sick", "powder", "day"];
  
  useEffect(() => {
    console.log("fresh render");
  }, [words]);

  return <h1>Open the console</h1>;
}

この場合、wordsは配列のリテラル値で初期化されるので、コンポーネントが描画されるたびに新しい配列のインスタンスが生成されてしまいます。

つまり、配列の内容は変わってないのに、異なる配列とみなされます。

その結果、console.log出力が描画のたびに行われます。

解決法

上記の解決法としては、wordsをコンポーネントの外で初期化することで解決することができます。

const words = ["sick", "powder", "day"];

function App() {
  
  useEffect(() => {
    console.log("fresh render");
  }, [words]);
  
  return <h1>Open the console</h1>;
}

コンポーネントのプロパティ値をもとに配列を生成する場合

上記は配列が定数であったため、宣言位置を変更することで解決ができましたが、配列がコンポーネントのプロパティ値によって変更となるような場合にはこの方法が使えません。

例えば以下のような場合

import { useEffect } from "react";

export function WordCount({ children = "" }) {
  const words = children.split(" ");

  useEffect(() => {
    console.log("fresh render");
  }, [words]);

  return (
    <>
      <p>{children}</p>
      <p>
        <strong>{words.length} - words</strong>
      </p>
    </>
  );
}

import { WordCount } from "./components/WordCount";

function App() {
  return (
    <>
      <WordCount>You are not going to believe</WordCount>
    </>
  );
}

export default App;

splitを使って、childrenから単語を抽出して配列wordsを生成しています。

配列wordsはpropsのchildrenによって作られるので、コンポーネントの外に出すことができません。

.splitを使った配列への変換は以下のようにおこなわれています。

children
> You are not going to believe

children.split(" ")
> ['You', 'are', 'not', 'going', 'to', 'believe']

そのため、wordsには毎回異なるインスタンスが代入され、文字列に変更がなくても、再描画のたびにconsole.logが出力されます。

useMemo

この再描画の問題をuseMemoというhookを使うことで解消することができます。

useMemoを使うことで、関数は、以前の呼び出し結果をその時の引数とともにキャッシュしておき、あとで同じ引数で呼び出されたとき、計算せずにそのキャッシュを返すことができます。

使用例を見ていきましょう!words関数にuseMemoを適用します。

useMemoは受け取った関数(この場合words)を実行して、その戻り値をそのまま返します。

依存配列にはchildrenを指定します。

  // この例ではuseMemoは関数wordsを受け取って、returnでwordsが返される
  const words = useMemo(() => {
    const words = children.split(" ");
    return words;
  }, [children]);

// 上記は以下のように省略して書くことができる。
const words = useMemo(() => children.split(" "), [children])

これで、childrenが無駄に再描画されないようになりました。

useCallback

useMemoに似た機能にuseCallbackがあります。

この2つの違いは、useMemoはメモ化された”値”を返すのに対し、useCallbackはメモ化された”関数”を返します。

使い方を説明します。

  const fn = () => {
    console.log("hello");
    console.log("world");
  };

  useEffect(() => {
    console.log("fresh render");
    fn();
  }, [fn]);

useEffectの依存配列に関数fnを指定しています。

これだと、描画のたびにfn関数が呼び出され、毎回新しいインスタンスが生成され、異なる関数とみなされます。

その結果、コンポーネントが描画されるたびに実行されてしまう。

これをuseCallbackを使ってメモ化します。

  const fn = useCallback(() => {
    console.log("hello");
    console.log("world");
  }, []);

  useEffect(() => {
    console.log("fresh render");
    fn();
  }, [fn]);

fnは初回描画時に初期化されてから以降は常に同一のインスタンスが代入され、fnは不変とみなされ、console.log出力は一度のみとなります。

useMemoを使ってみる

ここまでのuseMemoとuseCallbackの学んだことを生かして、先に作成したuseJazzNewsに機能を追加します。

新しい投稿がある度にチャイム音を鳴らす機能をつくっていきます。

変更前

import { useState, useEffect } from "react";

export const useJazzNews = () => {
  const [posts, setPosts] = useState([]);
  const addPost = (post) => setPosts((allPosts) => [post, ...allPosts]);

  useEffect(() => {
    newsFeed.subscribe(addPost);
    return () => newsFeed.unsubscribe(addPost);
  }, []);

  useEffect(() => {
    welcomeChime.play();
    return () => goodbyeChime.play();
  }, []);

  return posts;
};

変更後

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

export const useJazzNews = () => {
  // postsを_postsに変更
  const [_posts, setPosts] = useState([]);
  const addPost = (post) => setPosts((allPosts) => [post, ...allPosts]);

  // _postsをメモ化してpostsを作成
  // 新しいニュースの投稿があれば、_posts配列に要素が追加され、
  // postsに新しい配列のインスタンスが代入される。
  const posts = useMemo(() => _posts, [_posts]);

  useEffect(() => {
    newPostChime.play();
  }, [posts]);

  useEffect(() => {
    newsFeed.subscribe(addPost);
    return () => newsFeed.unsubscribe(addPost);
  }, []);

  useEffect(() => {
    welcomeChime.play();
    return () => goodbyeChime.play();
  }, []);

  return posts;
};

コンポーネントのパフォーマンス改善

Reactにおいてパフォーマンスを向上するベストプラクティスは不要な描画を抑え、描画回数を減らすことです。

ここで活躍するのがmemo, useMemo, useCallbackというわけです。

その対象となるmemo化する関数は純粋関数である必要があります。

純粋関数は、同じ引数で呼ばれた場合、常に同じ結果を返すものです。

これをmemo化することでパフォーマンスを改善することができます。

純粋関数の例を示します。

export const Cat = ({ name }) => {
  console.log(`rendering ${name}`);
  return <p>{name}</p>;
};

これは、nameプロパティの値が同じである限り描画内容も常に一定になります。

Catコンポーネントの描画側(親)

import { useState } from "react";

import { Cat } from "./components/Cat";

function App() {
  const [cats, setCats] = useState(["Biscuit", "Jungle", "Outlaw"]);
  return (
    <>
      {cats.map((name, i) => (
        <Cat key={i} name={name} />
      ))}
      <button onClick={() => setCats([...cats, prompt("Name a cat")])}>
        Add a Cat
      </button>
    </>
  );
}

export default App;

そうすると以下のような画面ができあがります。

初回描画時には、3つの名前を描画しており、console出力は以下のようになります。

rendering Biscuit
rendering Jungle
rendering Outlaw

「Add a Cat」ボタンより追加をすると、追加した分を含めて、新たに4つの名前が描画されます。

rendering Biscuit
rendering Jungle
rendering Outlaw
rendering pochi

Catの名前を追加するたびに、すべてのCatコンポーネントが再描画されてしまっています。

そこでmemo関数を使って、不要な描画を防ぐことにします。

Catコンポーネントをmemo化します。

import { memo } from "react";

const Cat = ({ name }) => {
  console.log(`rendering ${name}`);
  return <p>{name}</p>;
};

// memo関数を使ってCatコンポーネントをメモ化したPureCatコンポーネントを作成
export const PureCat = memo(Cat);

こうすることで、プロパティの値(この場合name)が変わらない限り、再描画されないようになります。

exportしているコンポーネントが変わったので、描画側も変更しましょう。

Catコンポーネントを描画していたところをPureCatに変更します。

import { useState } from "react";

import { PureCat } from "./components/Cat";

function App() {
  const [cats, setCats] = useState(["Biscuit", "Jungle", "Outlaw"]);
  return (
    <>
      {cats.map((name, i) => (
        <PureCat key={i} name={name} />
      ))}
      <button onClick={() => setCats([...cats, prompt("Name a cat")])}>
        Add a Cat
      </button>
    </>
  );
}

export default App;

確認してみます。

console出力を確認すると、以下の通り、再描画されていないことを確認できました。

プロパティが関数の場合

プロパティが文字列の場合は、上記の通りうまくいきました。

それでは、関数の場合はどうでしょうか?

meowというクリックのたびに実行される関数を追加します。

const Cat = memo(({ name, meow = (f) => f }) => {
  console.log(`rendering ${name}`);
  return <p onClick={() => meow(name)}>{name}</p>;
});

export const PureCat = memo(Cat);

      {cats.map((name, i) => (
        <PureCat
          key={i}
          name={name}
          meow={(name) => console.log(`${name} has meowed`)}
        />
      ))}

この追加により変更されていない名前も、全て再描画されるようになってしまいました。

このような現象になってしまう理由は、meowプロパティは関数であり、描画のたびに異なるインスタンスが生成されるからです。

そのため、meowプロパティの値が変化したとみなされ、Catコンポーネントが再描画されるというわけです。

これを解決するためにPureCatコンポーネントを以下のように変更します。

export const PureCat = memo(
  Cat,
  // nameフィールドの値が異なっている場合のみCatコンポーネントを再描画
  (prevProps, nextProps) => prevProps.name === nextProps.name
);

変更前に対し、第二引数が追加されています。

これはpredicateと呼ばれるものです。

predicateとは、真偽値を返すコールバック関数のことをいい、再描画するか否かを指定するために使用します。

trueを返した場合は再描画されず、falseを返した場合は再描画するといった挙動となります。

ただし、初回描画時は、predicateの戻り値に関わらず必ずコンポーネントが描画されます。

確認してみます。

追加した値以外は描画されていないことが確認できました。

また、クリック時にはクリックした要素がconsole出力されていることも確認することができました。

memo関数を使った再描画の削減をすることができ、パフォーマンスを上げることができました。

以上、読んでいただきありがとうございました。

コメント