React(React Native)でstateの更新時にどのように再レンダリングが走るか、理解が甘かったので実験してみました。
今回はFlatListを例にしています。
リストはFlatListを使っておけば取り敢えずOKや〜♪
と思っていましたが、思った以上に不要なレンダリングが走っていることが分かりました。
リストは項目数が増えると再レンダリングの影響も大きいので、改善の効果も大きいと思います。
実験0 親のstateが更新されると問答無用で子や孫も再レンダリングされる件
FlatListとは直接関係ないですが、Reactの再レンダリングを学ぶために実験してみました。
こんな感じで、Large > Middle > Smallの入れ子になったコンポーネントを用意しました。
そしてこれらと関係のないカウンターのstateを更新した再の再レンダリングを調べてみます。
// 上記の画面のコード
export default function App() {
  // FlatListと関係のないcount
  const [count, setCount] = useState<number>(1);
  return (
    <SafeAreaView style={styles.container}>
      {/* ヘッダー */}
      <View style={styles.header}>
        <Text style={styles.text}>Parent</Text>
        <View style={styles.row}>
          <Text style={styles.count}>{`count: ${count}`}</Text>
          <Button
            onPress={() => setCount(count + 1)}
            title="Update Parent State"
          />
        </View>
      </View>
      {/* 入れ子のLarge, Middle, Smallコンポーネント */}
      <LargeItem />
    </SafeAreaView>
  );
}
export const LargeItem = () => {
  console.log("render LargeItem");
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Large Item</Text>
      <MiddleItem />
    </View>
  );
};
export const MiddleItem = () => {
  console.log("render MiddleItem");
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Middle Item</Text>
      <SmallItem />
    </View>
  );
};
export const SmallItem = () => {
  console.log("render SmallItem");
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Small Item</Text>
    </View>
  );
};
実験0(改修前)
LargeItemさらにMiddleItem, SmallItemまで再レンダリングされています。
実験0(改修後)
各コンポーネントをmemo化します。
以下LargeItemの例。MiddleItem, SmallItemも同様にReact.memoで囲みます。
export const LargeItem = React.memo(() => {
  console.log("render LargeItem");
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Large Item</Text>
      <MiddleItem />
    </View>
  );
});
不要なレンダリングが無くなった!
実験1 FlatListと関係のないstateが更新されたときに、FlatListも更新される件。
カウンターとFlatListを持つ画面を用意して、カウンターを更新したときにどのようなrenderが走るか確認する。
// 上記の画面のコード
export default function App() {
  // FlatListと関係のないcount
  const [count, setCount] = useState<number>(1);
  // FlatListの子要素
  const [children, setChildren] = useState<Child[]>([]);
  useEffect(() => {
    setChildren([{ id: "1" }, { id: "2" }, { id: "3" }]);
  }, []);
  const renderItem = ({ item }: { item: Child }) => <ChildItem id={item.id} />;
  const keyExtractor = (item: Child) => item.id;
  return (
    <SafeAreaView style={styles.container}>
      {/* ヘッダー */}
      <View style={styles.header}>
        <Text style={styles.text}>Parent</Text>
        <View style={styles.row}>
          <Text style={styles.count}>{`count: ${count}`}</Text>
          <Button
            onPress={() => setCount(count + 1)}
            title="Update Parent State"
          />
        </View>
      </View>
      {/* リスト */}
      <FlatList
        data={children}
        renderItem={renderItem}
        keyExtractor={keyExtractor}
      />
    </SafeAreaView>
  );
}
why-did-you-renderというライブラリを利用してみます。
https://github.com/welldone-software/why-did-you-render
どのpropsのせいで再レンダリングが走ったかを教えてくれます。
PureComponentやReact.memoを使うときに助けになります。
てか名前が面白いです。
実験1 (改修前)
上記の初期状態でcountを更新してみます。
FlatListのレンダリングも走っています。
さらにFlatListの子コンポーネントChildItemたちもレンダリングされています。
childrenの項目数が増えると影響が大きそうです。
why-did-you-render のログを見てみます。
「props.renderItemが同じ名前だけど別のオブジェクトだよ」
とのこと。
props.keyExtractorも同様です。
renderItem, keyExtractorの関数が再生成されるため、実際は内容の変更がないFlatListの再レンダリングが走ってしまいます。
この辺はJavaScriptのややこしいところ。
実験1 (改修後)
renderItem, keyExtractorをuseCallbackを使って再生成しないようにしてみます。
  const renderItem = useCallback(
    ({ item }: { item: Child }) => <ChildItem id={item.id} />,
    []
  );
  const keyExtractor = useCallback((item: Child) => item.id, []);
FlatListの再レンダリングが無くなった!
(補足)
FlatList自体はPureComponentなので、FlatListにわたすpropsに変化がなければ、その配下は再レンダリングされない。しかし今回のように関数が再生成されて別オブジェクトになるのは気を付けないとですね。
https://reactnative.dev/docs/flatlist#example
実験2 リストに項目追加したときに、既存の項目まで再レンダリングされる件
childrenに項目を追加する「Add Child」ボタンを追加します。
FlatListに項目が追加されたときに、どのようにレンダリングが走るか確認してみます。
// App.tsx
// Add Childボタンの追加
<Button
  onPress={() => {
    console.log("On Press Add Child");
    setChildren([
      ...children,
      { id: (children.length + 1).toString() },
    ]);
  }}
  title="Add Child"
/>
実験2 (改修前)
こんな感じで子コンポーネントにconsole.logを仕込んで、リスト項目追加したときの挙動を確認してみます。
export const ChildItem = ({ id }: Props) => {
  console.log("render ", id);
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Flat List Item</Text>
      <Text style={styles.text}>{id}</Text>
    </View>
  );
};
追加した4だけでなく、既存の1~3もrenderされています。
(2周分renderされているのは謎)

実験2 (改修後)
子コンポーネントをmemo化してみます。
memo化することでChildItemのpropsに変更がない場合(浅い比較)は再レンダリングしなくなるはず。
export const ChildItem = React.memo(({ id }: Props) => {
  console.log("render ", id);
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Child</Text>
      <Text style={styles.text}>{id}</Text>
    </View>
  );
});
新しく追加した4だけレンダリングされています!
まとめ
調べてみると、意外なところでレンダリングが走っていることが分かりました。
アロー関数が再生成されることや、propsが変わってない子コンポーネントまでレンダリングされることはちょっと罠ですね(?)。
FlatListなどは項目数が多くなると、その分レンダリング負荷への影響も大きいので、パフォーマンスが気になったときは見直してみる価値はあると思います。
一方で、初めからこのようなパフォーマンス改善はしないことが推奨されています。
useCallbackやmemoは、気を付けてdependency listを設定していないと、思わぬバグを生みかねません。(そしてこの手のバグは発見しにくい..)
またシンプルな画面なら、memo化によるprops比較が逆にコストになる場合もあります。
なのでパフォーマンスが気になったときに、これらを疑ってみると良いと思います。
参考
お前らのReactは遅い
https://qiita.com/teradonburi/items/5b8f79d26e1b319ac44f
雰囲気で使わない React hooks の useCallback/useMemo
https://qiita.com/seya/items/8291f53576097fc1c52a
React.memoを利用したパフォーマンスチューニング
https://note.com/green_grass_grow/n/n0703595eafb9
React Native Performance Optimisation With Hooks
https://dev.to/ltsharma24/performance-optimisation-react-native-with-hooks-a77








