4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【React】レンダリング制御について (React.memo / useCallback / useReducer)

Posted at

はじめに

React のコンポーネントのレンダリングの条件とどのように不必要な再レンダリングを防ぐかについて、手元で動かして確認しながらまとめました。
誤り等ありましたらコメントでご指摘いただけますと幸いです :pray:

レンダリングの条件

まず、再レンダリングとはどのようなことを指すのでしょうか。
コンポーネントは以下のような流れで画面上に表示、更新されます。

レンダリング -> マウント -> 【state の更新など何かしらのイベント】 -> 再レンダリング -> 仮想DOMの差分を検知 -> リアルDOMに差分を反映 -> ... -> アンマウント

一度 DOM にマウントされたコンポーネントに state の更新などが生じると、React はコンポーネントを再度レンダリングします。state の更新以外にも、親コンポーネントがレンダリングされた場合や渡される props が更新された場合などもレンダリングが走ります。

state の更新によるレンダリング

useState のセッター関数が呼ばれたときなど、state が更新されることによりレンダリングが走ります。
例えば、以下のようなコンポーネント。

const App = () => {
  console.log("Appレンダリング!")

  const [count, setCount] = useState(0);

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

  return (
    <>
      <div>
        {count}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
    </>
  );
}

CountUp ボタンを押すたびにこのコンポーネントのレンダリングが走ります。この再レンダリングは描画の変更の有無に関わらず実行されるので、例えば {count} をdivタグごと削除したとしても CountUp ボタンを押した時にレンダリング処理が走ります。

親コンポーネントのレンダリングによるレンダリング

親コンポーネントがレンダリングされると、渡される props の変更の有無に関わらずレンダリングが走ります。つまりコンポーネントツリーのルートコンポーネントがレンダリングした場合、その子コンポーネント、孫コンポーネントと連鎖的にレンダリング処理が走ります。
以下のように親コンポーネントにあたる App コンポーネントから子コンポーネントにあたる Child コンポーネントを呼んでみます。

const Child = () => {
  console.log("Childレンダリング!")

  return (
    <div>
      Childコンポーネント!
    </div>
  );
}

const App = () => {
  console.log("Appレンダリング!")

  const [count, setCount] = useState(0);

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

  return (
    <>
      <div>
        {count}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
      <Child />
    </>
  );
}

親コンポーネントにあたる App コンポーネントにレンダリングが走ると、子コンポーネントである Childコンポーネントにもレンダリング処理が走ります。例え props に変更がなくても(props 自体がなくても)子コンポーネントは再レンダリングします。

再レンダリングの防止方法

レンダリングコストの高いコンポーネントの場合、不必要な再レンダリングをメモ化で未然に防ぐことによりパフォーマンスの向上が見込めます。
(メモ化自体がコストのかかる処理であるため、必要以上にメモ化で再レンダリングを防ごうとすると返ってパフォーマンスが悪くなる可能性もあります。)

React.memo による再レンダリング防止

React.memo() 関数にコンポーネントを渡すことにより、props の変更の有無を確認して再レンダリングが制御されるラッパーコンポーネントが返されます。

const Child = React.memo(({message}) => {
  console.log("Childレンダリング!")

  return (
    <div>
      {message}
    </div>
  );
})

const App = () => {
  console.log("Appレンダリング!")

  const [count, setCount] = useState(0);

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

  return (
    <>
      <div>
        {count}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
      <Child message={"Childコンポーネント!"}/>
    </>
  );
}

上のように React.memo() で Child コンポーネントをラップすることにより、親コンポーネントのレンダリング による再レンダリングを防ぐことができます。
(props として count を渡すようにすると、ボタンクリックの度に渡される props の値が変わるので、ちゃんと Child コンポーネントも再レンダリングされるようになります。)

ここで、props として渡している値が関数であった場合では React.memo() のみの制御ではうまくいかなくなります。なぜなら、関数はレンダリングごとに別参照のオブジェクトとして生成されるため、props が等価でないと判断されるためです。

const Child = React.memo(({action}) => {
  console.log("Childレンダリング!")

  return (
    <button onClick={action}>
      Action!
    </button>
  );
})

const App = () => {
  console.log("Appレンダリング!")

  const [count, setCount] = useState(0);

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

  const sayHello = () => {
    console.log('Hello!');
  }

  return (
    <>
      <div>
        {count}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
      <Child action={sayHello}/>
    </>
  );
}

CountUp! ボタンを押して App コンポーネントが再レンダリングする度に新しい参照の sayHello 関数が渡され、Child コンポーネントのレンダリングも走ります。
このような場合は useCallback の使用を検討します。

useCallback による再レンダリング防止

useCallback に関数を渡すことにより、メモ化をおこなってくれます。また第二引数に依存配列を渡すことによりそのメモ化をコントロールすることができます。今回はただの文字列をコンソール出力する関数なので依存配列は空配列とします。

const Child = React.memo(({action}) => {
  console.log("Childレンダリング!")

  return (
    <button onClick={action}>
      Action!
    </button>
  );
})

const App = () => {
  console.log("Appレンダリング!")

  const [count, setCount] = useState(0);

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

  const sayHello = useCallback(() => {
    console.log('Hello!');
  }, [])

  return (
    <>
      <div>
        {count}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
      <Child action={sayHello}/>
    </>
  );
}

このようにして関数をメモ化することにより、CountUp! ボタンを押すたびに Child コンポーネントが再レンダリングされるのを防ぐことができます。
また、渡す props をメモ化しても Child コンポーネント自体がメモ化されていなかったら普通に再レンダリングが走ってしまうので注意が必要です。(親コンポーネントがレンダリングされていることに変わりはないため)

最後に、もう一捻り加えてみます。
message という state を追加して、count を2倍した値を使って message を更新する関数を props として渡してみます。依存配列には count を入れる必要があります。( count を依存に設定しないと、いくら CountUp! ボタンをクリックしても Action! ボタンをクリックしたときの出力が Double : 0 のままになってしまいます)

const Child = React.memo(({action}) => {
  console.log("Childレンダリング!")

  return (
    <button onClick={action}>
      Action!
    </button>
  );
})

const App = () => {
  console.log("Appレンダリング!")

  const [count, setCount] = useState(0);
  const [message, setMessage] = useState("initialized...");

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

  const updateMessage = useCallback(() => {
    setMessage(() => `Double : ${count * 2}`);
  }, [count])

  return (
    <>
      <div>
        {count}
      </div>
      <div>
        {message}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
      <Child action={updateMessage}/>
    </>
  );
}

CountUp! ボタンを押すたびに updateMessage 関数が新しい count に基づいて作られるため、どうしても Child コンポーネントのレンダリングが走ってしまいます。
props として渡している関数が state に依存しているのが再レンダリングの原因であり、このような場合に Child コンポーネントの再レンダリングを避けたいときは、useReducer により「 state に非依存な関数」を props として渡すようにする方法があります。

useReducer での state 管理

使い方については省略しますが、useReduceruseState のような state を管理するためのフックのひとつ(というか、useState は内部的に useReducer を使っています。)です。重要なのは、今回の場合 useReducer を使用することにより dispatch という state に依存していない関数を props として渡すことができるようになる、ということです。updateMessage 関数の中で count を参照していたような処理は reducer の中に閉じ込めることができます。

まずは、reducer を下のように作成します。

const initialState = {
  count: 0,
  message: "initialized..."
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'count/increment':
      return {
        ...state,
        count: state.count + 1
      }
    case 'message/update':
      return {
        ...state,
        message: `Double : ${state.count * 2}`
      }
    default:
      return state;
  }
};

countmessage をシングルツリーの state オブジェクトの中にまとめることにより、「既存の state を受け取って新しい state オブジェクトを返す」というピュアな reducer 動きの中に処理を押し込むことができます。
あとは、しかるべき action を dispatch する関数をコンポーネントに配置すればよいだけなので、以下のように依存のない関数を porps として渡すことができるようになります。

const Child = React.memo(({action}) => {
  console.log("Childレンダリング!")

  return (
    <button onClick={action}>
      Action!
    </button>
  );
})

const App = () => {
  console.log("Appレンダリング!")

  const [{ count, message }, dispatch] = useReducer(reducer, initialState);

  const incrementCount = () => {
    dispatch({ type: 'count/increment' });
  }

  const updateMessage = useCallback(() => {
    dispatch({ type: 'message/update' });
  }, [])

  return (
    <>
      <div>
        {count}
      </div>
      <div>
        {message}
      </div>
      <button onClick={incrementCount}>
        CountUp!
      </button>
      <Child action={updateMessage}/>
    </>
  );
}

これで、CountUp! ボタンを押してもその度に Child コンポーネントがレンダリングされるのを防げるようになりました。 Action! ボタンを押しても正常に動作します。
今回は dispatch を呼ぶ場所を App コンポーネントにまとめていますが、<Child dispatch={dispatch}/> のようにして Child コンポーネントから dispatch するやり方もあります。その場合、useCallback のラップがなくなりすっきりしそうです。

まとめ

今回挙げたもの以外にも、useMemo というメモ化された値を返すフックもあります。
繰り返しになりますが、メモ化自体にコストがかかるため、パフォーマンス計測などを通して適切にコンポーネントのレンダリングを制御することが大切だと思います!

参考

https://qiita.com/hellokenta/items/6b795501a0a8921bb6b5
https://qiita.com/soarflat/items/b9d3d17b8ab1f5dbfed2
https://qiita.com/uhyo/items/cea1bd157453a85feebf

4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?