【React】配列の map メソッドで生成した input 要素の onChange ハンドラーが実行されるとまとめて全部再レンダリングされる問題のよくある原因と修正方法

  • 2022年6月16日
  • 2022年6月17日
  • React

 React のパフォーマンス問題でよくあるのが過度な再レンダリングです。これは主に子コンポーネントに渡す props が意図せぬタイミングで異なる値(ここでは”前回レンダリング時の値”!==”今回レンダリング時の値”が成立する値)に書き変わっているために起きます。例えば、次の場合があります。

const Example = () => {
    return <div>
        <ExampleChildHoge piyo={new PiyoClass()} >
        <ExampleChildFuga piyo={new PiyoClass()} >
    </div>
}

 この場合、毎回 new PiyoClass して PiyoClass の異なるインスタンスを渡しているため再レンダリングが走ってしまいます。初期化されたインスタンスが一度欲しいだけならば state を、何度も新しいインスタンスが欲しいならインスタンスを返す変わらない関数を渡すべきです。

const makePiyo = () => new PiyoClass();
const Example = () => {
    const [onceNewPiyo] = useState(new PiyoClass());
    return <div>
        {/* state管理されている一つの Piyo が渡されます */}
        <ExampleChildHoge piyo={onceNewPiyo} >
        {/* 毎回同じ makePiyo 関数 が渡されます。この場合、子コンポーネント内で関数を実行して piyo をインスタンスにします */}
        <ExampleChildFuga piyo={makePiyo} >
    </div>
}

 この問題の厄介なところは毎回同じものを渡していると思い込みやすいところにあります。変わらない定義で同じ意味合いの値が子コンポーネントに渡る様に props を記述しても異なる値が props として渡される、といった状況になりやすいです。

 配列でインデックスに応じた処理を書いている場合、これは特に起きやすいです。典型的な例(というか自分が何度も踏んでいる例)が次です。

/** ランダムな文字色、背景色のCSSスタイルを返す関数。色を変えてレンダリングが起きたことを分かりやすくします */
const makeRandomColorStyle = () => {
  const getColor = () => Math.floor(Math.random() * 255);
  return {
    color: `rgb(${getColor()},${getColor()},${getColor()})`,
    backgroundColor: `rgb(${getColor()},${getColor()},${getColor()})`,
  };
};

/** input 要素のラッパー関数コンポーネントです */
const AppInput = React.memo(({v,onChange}) => {
  return <input style={makeRandomColorStyle()} type="text" value={v} onChange={onChange}/>
})

/** 意図せぬ再レンダリングが起きる例のクラスコンポーネントです */
class SoManyRerender extends React.Component {
  constructor(props) {
    super(props);
    // 配列で入力欄の値を state として保持します
    this.state = {inputList: [1, 2, 3]};
    this.handleChange = this.handleChange.bind(this)
  }

  /** 起きた変更を index 番目の要素に適用して state を更新します */
  handleChange(event, index) {
    const inputList = [...this.state.inputList];
    inputList[index] = event.currentTarget.value;
    this.setState({
      inputList
    })
  }
  
  render() {
    return <div>
      {this.state.inputList.map((v, i) => (<div key={i}>
        {/* 入力欄の配列を元に input 要素を並べています。 */}
        {/* ここでアロー関数を使って無名関数を定義しているのがまずいです */}
        <AppInput v={v} onChange={(e) => this.handleChange(e, i)}/>
      </div>))}
    </div>
  }
}

 ↑を動かしたデモが次です。一つの入力欄の値を変更すると他の入力欄もまとめて変更されてしまいます。

 これの対策のためには少々手間ですが、不変の関数を用意して渡してやる必要があります。これは例えば次の様にできます。

/** ランダムな文字色、背景色のCSSスタイルを返す関数。色を変えてレンダリングが起きたことを分かりやすくします */
const makeRandomColorStyle = () => {
  const getColor = () => Math.floor(Math.random() * 255);
  return {
    color: `rgb(${getColor()},${getColor()},${getColor()})`,
    backgroundColor: `rgb(${getColor()},${getColor()},${getColor()})`,
  };
};

/** ここから下が変更点です */

/**
 * input 要素のラッパー関数です。
 * 取り扱う値をインデックスや ID といった識別子付きオブジェクトである listItem で受け取り、
 * onChange の際にはそれも返します。
 */
const AppInput = React.memo(({v, listItem, onChange}) => {
  return <input style={makeRandomColorStyle()} type="text" value={v}
                onChange={(e) => onChange(e, listItem)}/>
})

class LittleRerender extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      // 配列で入力欄の値を state として保持します
      // index を id として持つようにしています。
      // これにより配列内の要素単独でも何番目のどの要素かがわかります
      // 実際に用いるならば map メソッド等で index を拡張するのが楽です
      // 例えば次ならば元々の要素が item の中に、インデックスが index の中に格納されたオブジェクトが配列の要素になります
      //  ex. arr.map((item, index) => ({item, index})) 
      inputList: [
        {id: 0, v: 1},
        {id: 1, v: 2},
        {id: 2, v: 3},
      ]
    };
    this.handleChange = this.handleChange.bind(this)
  }

  /**
   * 起きた変更を index 番目の要素に適用して state を更新します。
   * 今度は要素自体の持つ id プロパティを用いて変更するべき場所を決めています。
   * これにより、map メソッド経由で決まる index を渡されなくても特定の要素を更新できます
   */
  handleChange(e, listItem) {
    const inputList = [...this.state.inputList];
    inputList[listItem.id].v = e.currentTarget.value;
    this.setState({
      inputList
    })
  }

  render() {
    return <div>
      {this.state.inputList.map((listItem, i) => (<div key={i}>
        {/* 入力欄の配列を元に input 要素を並べています。 */}
        {/* アロー関数を使っていた場所がただの this.handleChange で済むようになりました */}
        <AppInput v={listItem.v} listItem={listItem} onChange={this.handleChange}/>
      </div>))}
    </div>
  }
}

 一応 React の再レンダリング用の計算を上書きする方法はあるのでそちらでも過度の再レンダリングは防げるはずですが、後にわかりにくい不具合を埋め込みやすいです。それをするぐらいなら回りくどい方法でレンダリングを制御しつつコメントで「~のためにほげほげしている」など書く方が好みです。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG