2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

朝日新聞社Advent Calendar 2023

Day 14

Reactのメモ化は最終手段にしたほうが良いという話

Last updated at Posted at 2023-12-13

memo化について

Reactでは親コンポーネントが再レンダリングされると、子のコンポーネントも再レンダリングされてしまいます。

例えば、以下のような例だと親コンポーネントでcount upボタンを押すたびに、
子のExpensiveComponentも再レンダリングされてしまいます。

ExpensiveComponentが非常に重い処理が含まれていると、count upボタンを押すたびに重い処理が走ってしまい、パフォーマンス悪化につながってしまいます。

import React,{useState} from 'react';

export function App() {
  [count,setCount]= useState(0);
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <p>{count}</p>
      <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>

      <ExpensiveComponent/>
    </div>
  );
}

function ExpensiveComponent() {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return <p>expensive</p>
}

not_memo_rendering.gif

この挙動を防ぐためにmemo化を行います。

以下のように子コンポーネントをmemo化することで、親コンポーネントが再レンダリングされたときでも、子コンポーネントが再レンダリングされることはなくなります。

import React,{useState} from 'react';

export function App() {
  [count,setCount]= useState(0);
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <p>{count}</p>
      <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>

      <ExpensiveComponentMemo/>
    </div>
  );
}
const ExpensiveComponentMemo = React.memo(ExpensiveComponent);

function ExpensiveComponent() {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return <p>expensive</p>
}

momo_render.gif

メモ化の問題点

memo化を用いた場合、将来的に開発を行なっていく中で、実装上のちょっとした変更で想定通り動かなくなってしまうという懸念点が挙げられます。

propsを渡すパターン

例えば、次のようにExpensiveComponentにstyleをpropsで渡すようにしたらどうでしょうか?

一見ExpensiveComponentに渡されるものは常に同一なので、想定通り動きそうに思えますが、親コンポーネントのレンダリングに合わせて子コンポーネントもレンダリングされてしまい、memo化が機能しないコードになってしまいました。

import React,{useState} from 'react';

export function App() {
  [count,setCount]= useState(0);
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <p>{count}</p>
      <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>

      <ExpensiveComponentMemo style={{"color":"red"}}/>
    </div>
  );
}
const ExpensiveComponentMemo = React.memo(ExpensiveComponent);

function ExpensiveComponent({style}) {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return <p style={style}>expensive</p>
}

props_rendering.gif

これはReactのPropsの差分はObject.isで同一であれば差分なしでレンダリングしない、同一でなければ差分ありと判断されレンダリングされるためです。(https://ja.react.dev/reference/react/memo#minimizing-props-changes)

Object.isでは両方の値がメモリー内の同じオブジェクトを参照している場合のみtrueになるので
下記のような場合falseになってしまいます。(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/is)

// falseになってしまう!!
Object.is({"color":"red"},{"color":"red"})

この挙動によってobjectの中身は実際には変わっていないにもかかわらず、Reactの内部的には変わってしまったものと判断され再レンダリングが走ってしまいます。

これを解決するためにはuseMemoを使います。
これによりmemoStyleObject.isでの比較でも同一のものと判断され
子コンポーネントが再レンダリングされることはなくなります。

import React,{useState} from 'react';

export function App() {
  [count,setCount]= useState(0);
  const memoStyle = React.useMemo(()=>({"color":"red"}),[])
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <p>{count}</p>
      <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>

      <ExpensiveComponentMemo style={memoStyle}/>
    </div>
  );
}
const ExpensiveComponentMemo = React.memo(ExpensiveComponent);

function ExpensiveComponent({style}) {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return <p style={style}>expensive</p>
}

rendering_props_memo.gif

しかし、渡すpropsが増えた際に全ての開発者がこの挙動を予期してちゃんとuseMemoを使用できるかというと怪しい部分があり、いざパフォーマンスの低下が検知されて初めて実装ミスに気づくといったこともあると思います。
このような罠に気づかずハマるおそれがあるというのが、memo化をなるべく避ける理由の一つです。

childrenを渡しているパターン

childrenを渡しているパターンも同様に考えてみます。

この場合も、一見ExpensiveComponentchildrenは同一なので想定通り動きそうに思えますが、親コンポーネントのレンダリングに合わせて子コンポーネントもレンダリングされてしまい、memo化が機能しないコードになってしまいました。

import React,{useState} from 'react';

export function App() {
  [count,setCount]= useState(0);
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <p>{count}</p>
      <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>

      <ExpensiveComponentMemo>
        <p>children</p>
      </ExpensiveComponentMemo>
    </div>
  );
}
const ExpensiveComponentMemo = React.memo(ExpensiveComponent);

function ExpensiveComponent({children}) {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 1000;i++){
    i= i+1;
  }
  return <div>expensive{children}</div>
}

render_children.gif

jsx記法はReact.createElementの糖衣構文であり、実際は新しいobjectが作成されてしまっているので、先ほどの場合と同様にこの事象が起こってしまっています。

こちらの場合もuseMemoを用いることでレンダリングを防ぐことは可能ですが、
可読性や、変更時の柔軟性を考えるとバグを生みやすいコードになってしまいます。

memoに変わる代替案

ここまで、記述した通りmemo化は思わぬところに落とし穴が多く、複数人で開発し引き継ぎを行っていくようなプロジェクトだと気づかずにバグを混入させてしまう可能性が高く、あまり使用しないことを推奨したいです。
しかし、パフォーマンス上の懸念からどうしても再レンダリングを避けたい時の解決策を紹介します。

stateを用いているコンポーネントを分離させる

countstateを使っているのは一部だけなのでその部分を別のコンポーネントに切り出すというのが一つです。

これによって、countによって変化を受けるコンポーネントは
CountComponentだけになるのでcountの変化によってExpensiveComponentが再レンダリングを避けることができます。

import React,{useState} from 'react';

export function App() {
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <CountComponent/>
      

      <ExpensiveComponent/>
    </div>
  );
}

function CountComponent(){
 [count,setCount]= useState(0);
 return <p>{count}</p>
}


function ExpensiveComponent(props) {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return (
  <>
  <p>expensive</p>
  <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>
  </>
  )
}

childrenを用いてコンポーネントを分離させる

次のようなDOM構造を維持したい場合、単純にstateを用いているコンポーネントを分離させることが難しいように思えます。

import React,{useState} from 'react';

export function App() {
  [count,setCount]= useState(0);
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <div className='count'>{count}</div>
      <div>
          <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>
          <ExpensiveComponent/>
      </div>
    </div>
  );
}

function ExpensiveComponent() {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return <p>expensive</p>
}

この場合でも、下記のようにchildrenとして重い処理をしている子コンポーネントを渡しCountComponentで表示することにより、countの変化によるExpensiveComponentの再レンダリングを避けることができます。

import React,{useState} from 'react';

export function App() {
  return (
    <div className='App'>
      <h1>Hello React.</h1>
      <CountComponent>
          <ExpensiveComponent/>
      </CountComponent>
    </div>
  );
}

dunction CountComponent({children}){
    [count,setCount]= useState(0);
    return (
       <>
          <div className='count'>{count}</div>
          <div>
              <button type="button" onClick={()=>setCount((count)=>count+1)}>count Up!!!</button>
              {children}
          </div>
        </>
      );
}

function ExpensiveComponent() {
  console.log("rendering");
  //なんらかの重い処理
  for (let i = 0; i <= 100000;i++){
    i= i+1;
  }
  return <p>expensive</p>
}

まとめ

コンポーネントのメモ化は、実装した際にはパフォーマンスを向上できるものの、その後の保守運用フェーズで気付かぬうちにバグを生み出してしまう可能性も高いです。
今回の代替案でご紹介したものなどmemo化しないで実装できないかをまず考えることが大事だと思いました。

参考文献

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?