🤔

【React】なぜコンポーネントの中でコンポーネントを作るのは良くないのか?

2024/09/06に公開
3

こんにちは、ダイニーの Feature team でソフトウェアエンジニアをしている @ta21cos です。
最近は新規事業である決済関連の機能の開発をメインに行なっています。
ダイニーにおける Feature team は機能にフォーカスした開発・運用を行っているチームです。最近は複数の事業毎に Unit として分かれて開発を進めています。

本日は、普段の開発で実際にあった Pull Request のレビューコメントから得た学びについて紹介します。

Dialog を実装しよう

React を使ってある Dialog を作成するため、以下のようなコードを書きました(コードは簡略化しています)。

// useSample ファイル
// 内部でロジックと Dialog を同時に定義している hook

const Dialog = memo<{ progress: number, ... }>(({ ... }) => { ... })

const createSampleDialogComponent = useCallback(({ Layout }: { Layout: ... }) =>
  () =>
    isDialogVisible ? (
      <Dialog
        progress={progress}
        Layout={Layout}
        ...
      />
    )  : null,
  [isDialogVisible, progress, ...],
);
		
===========

// 利用側
const { createSampleDialogComponent, ... } = useSample();

// 見た目の情報は hook でなく表示側で注入する
const SampleDialog = useMemo(() =>
  createSampleDialogComponent({ Layout: DialogLayout }), [createSampleDialogComponent]);

...(省略)...
	
return (
  ...
  <SampleDialog />
  ...
)

今見ると改善点が残っているコードですが、今回はコンポーネントの作成方法について絞って見ていきます。まずはコードの意図を説明します。

この Dialog は特定の処理と関連して表示され、ロジックに関する部分を useSample という hook にまとめて定義しています。見た目部分の責務を分離するため、hook は Dialog を生成する関数しか露出せず、Layout という見た目を表したコンポーネントを hook を利用する側で注入して生成しています。動作としては、progress が変わるたびに表示が変わっていくようなものになっています。

このような書き方をする場合、 createSampleDialogComponent を rename して {renderSampleDialog()} とするコードを良く見かけると思いますが、上記のように書けば <SampleDialog /> と表現できるのでは?と当時の私は考えていました。

いつも通り動作確認完了後に Pull Request を提出すると、レビュワーから次のようなコメントを頂きました(実際のレビューコメントです)。

私はこのコメントを頂いてもすぐには理解できず、さらに質問したところ大変ありがたいことに簡略化した例で説明してもらえました。

更新ありがとうございます! ややこしいので簡略化したコードで説明しますね!

const Component = () => {
 const [state, setState] = useState("something");

 const InnerComponent = useCallback(() => <Text>{state}</Text>, [state]);
 const renderInner = useCallback(() => <Text>{state}</Text>, [state]);

 return (
   <View>
     <InnerComponent />
     {renderInner()}
   </View>
 );
};

実はここでの <InnerComponent /> と {renderInner()} は意味が結構違います。
前者は ReactElement (要は JSX.Element)として配置されて、実際の値は { type: InnerComponent, props: {}, key: null } 的な感じになります。後者は戻り値の <Text>{state}</Text> がそのまま入ります。

state が変化すると InnerComponentrenderInner はそれぞれ新しいオブジェクトになります。 renderInner の方はまた <Text>{state}</Text> を返して、props (children)に差が生まれて Text コンポーネントが再評価されます。 InnerComponent も再評価はされるのですが、 type が異なるので React は全く別のコンポーネントが渡されたと解釈し、以前のインスタンスを破棄して新しくインスタンスを作成します。(クラスではないのでインスタンスという表現は最適ではないですが、意味合い的にはインスタンスが一番伝わるかと思います) DOM を例にして考えると、 <div /> を <span /> に変えているのと同じようなことが起きていると思ってください。
(以下略)

(この PR は ReactNative のコードなので、 <View /> が使われています。)

これだけでも理解できた方はいらっしゃるかもしれませんが、当時の私はこれでもなかなか腑に落ちませんでした。

なぜ {renderInner()} のほうが処理を抑制できるのでしょうか?

私が理解するには、React の少し deep な仕様まで調べる必要がありました。

JSX はどのように ReactElement に変換されるのか?

まずは評価の仕組みです。React のコードを書くうえで、コンポーネントの返り値の部分には次の2つのパターンを書くと思います。

  • <Component .. />
  • {式}

この2つは評価のされ方が異なります。

1. <Component … />

このケースでは、ReactElement(React 内部での HTML 要素の表現) には Component がそのまま使われます。次のような値のイメージです。

{
  type: Component, // コンポーネントは関数で表現される
  props: {...},
  key: null,
  childern: ...,
  ...
}

2. {式}

const render = useCallback(() => <p>Hello!</p>, [])
...
return (
  ...
  {render()}
  ...
}

における {render()}<p>Hello!</p> となり、ReactElement にすると

{
  type: "p",
  key: null
  props: {
    ...
    children: "Hello!
  },
  ...
}

となります。

React の state が変わったときに何が起こるのか?

こちらは React を普段から使っている方からすればおさらいになります。

コンポーネント内で定義している state が変化した場合、コンポーネントの関数自体が再度実行されます。返り値である React 要素を用いて、前回からの差分を計算します(いわゆる Reconciliation)。ここで差分のあった React 要素に対して HTML の再構築を行います。

以上の内容を踏まえて、レビューコメントのコードを見てみます。

依存が変化したときの挙動が変わる

レビューコメントにあったサンプルコードに戻り、わかりやすくするためのログを付与します。

const InnerImpl = memo(({ name }: { name: string }) => {
  useEffect(() => {
    console.log(`${name} re-rendered!`);
  }, [])
  return null
});

const Component = () => {
  const [state, setState] = useState("default");
  
  useEffect(() => {
    setTimeout(() => {
      setState("updated");
    }, 2000);
  }, []);

  const InnerComponent = useCallback(() => {
    return <InnerImpl name="asComponent" />;
  }, [state]);
  
  const renderInner = useCallback(() => {
    // InnerImpl は state への依存がない
    return <InnerImpl name="asFunction" />;
  }, [state]);

  return (
    <>
      <InnerComponent />
      {renderInner()}
    </>
  );
};

これをよしなに実行すると、次のようなログが出ます。

asComponent re-rendered!
asFunction re-rendered!
asComponent re-rendered!
asFunction re-rendered!
// 2秒後
asComponent re-rendered!
asComponent re-rendered!

(strict mode によって2回 useEffect が実行されていますが)2秒後の更新時に InnerComponent が再度描画されているのは asComponent のみとなりました。

このからくりは、先程の評価方法を考慮しrenderInner を書き下すとわかります。

まず、renderInner は依存している state が変わるたびに評価されますが、return 部分は常に <InnerImpl name="asFunction" /> を返す関数なので、評価された結果の React 要素は常に一定となります( InnerImpl はコンポーネントの外で定義されたオブジェクトなので、参照は一定になる)。

{
  type: InnerImpl, // コンポーネントの外で定義されたオブジェクトなので不変
  props: {...},
  key: null,
  childern: ...,
  ...
}

state が変わっても <InnerImpl name="asFunction" /> は同一と判断されるので、こちらは再描画されないわけですね。

一方で InnerComponent はそれ自体が React 要素の type となります。そのため、state が変化し InnerComponent 関数自体が再生成されると、これは異なるオブジェクトになります。その結果 React 要素の type のオブジェクトが変化するため、React は全く異なるコンポーネントが格納されたと判断し、ゼロから再描画を行います。

{
  type: InnerComponent, // state の変化で再生成されるとオブジェクトが変わる
  props: {...},
  key: null,
  childern: ...,
  ...
}

冒頭のコード

サンプルの例は state を依存として指定しているものの実際に返す値は state には依存しないという特殊な例でした。改めて実際のコードで何が問題だったかを整理します。

// useSample ファイル
// 内部でロジックと Dialog を同時に定義している hook

const Dialog = memo<{ progress: number, ... }>(({ ... }) => { ... })

const createSampleDialogComponent = useCallback(({ Layout }: { Layout: ... }) =>
  () =>
    isDialogVisible ? (
      <Dialog
        progress={progress}
        Layout={Layout}
        ...
      />
    )  : null,
  [isDialogVisible, progress, ...],
);
		
===========

// 利用側
const { createSampleDialogComponent, ... } = useSample();

// 見た目の情報は hook でなく表示側で注入する
const SampleDialog = useMemo(() =>
  createSampleDialogComponent({ Layout: DialogLayout }), [createSampleDialogComponent]);

...(省略)...
	
return (
  ...
  <SampleDialog />
  ...
)

createSampleDialogComponent の依存する state のいずれかが変化すると、useMemo により SampleDialog も更新され、新たなオブジェクトになります。その結果異なる React 要素として解釈されてしまい、Dialog がゼロから再描画されることになります。

確認ダイアログのように、visible フラグで表示・非表示が切り替わるだけであればどのタイミングでも全体が再描画されるので大きな問題はないのですが、今回のケースでは progress という state によってダイアログの表示中も内部の表示を更新しているため、この変化のたびにダイアログ全体が再描画されることになってしまいます。

上記のコードの問題点は、コンポーネントの中でコンポーネントを定義してしまっていたことでした。すなわち、利用側のコンポーネントの宣言の中で、 SampleDialog というコンポーネントを作ってしまっていました。コンポーネントの中で作られたコンポーネントは、利用側コンポーネントのライフサイクルに合わせて毎度再生成されてしまうため、パフォーマンスが悪化してしまう傾向にあります。

再描画コストを減らしたコード例も次に示します。

// useSample ファイル
// 内部でロジックと Dialog を同時に定義している hook

const Dialog = memo<{ progress: number, ... }>(({ ... }) => { ... })

const renderSampleDialogComponent = useCallback(({ Layout }: { Layout: ... }) =>
  isDialogVisible ? (
    <Dialog
      progress={progress}
      Layout={Layout}
      ...
    />
  )  : null,
  [isDialogVisible, progress, ...],
);
		
===========

// 利用側
const { renderSampleDialogComponent, ... } = useSample();

...(省略)...
	
return (
  ...
  {renderSampleDialogComponent({ Layout: DialogLayout })}
  ...
)

よく見るようなコードですね。こちらであれば renderSampleDialogComponent がその場で評価され、React 要素には Dialog が格納されることになります。加えてこの Dialog は(isDialogVisible の変化でなければ)常に React 要素の type は同一と判断できます。

state のいずれかが変化した際は React 要素の props が変化しますが、ハンドリングは Dialog のコンポーネント自体の評価側で行うことができます。(ここから先は私の理解も深くないので省略させていただきますが)これにより、少なくとも再描画を抑えることができる可能性が上がります。

まとめ

今回触れたような実装のパフォーマンスへの影響は、実際の影響としては大きな問題となることは少ないとは思います。しかし、React に意図されている方法で実装されているかは重要です。React の意図している方法で実装されているからこそ、React は最もパワフルなパフォーマンスを発揮できます。

この記事では React の少し deep な仕様までとりあげて、コードの改善例を紹介しました。ダイニーでは引用したコメントのような、リスペクトを伴ったレビューを積極的に行う文化があります。

レビューが活発なチームで働きたい方は、ぜひ一度お話しましょう。

https://hrmos.co/pages/dinii/jobs/0001

Discussion

YAMAMOTO YujiYAMAMOTO Yuji

更新ありがとうございます! ややこしいので簡略化したコードで説明しますね!

で例示した

const InnerComponent = useCallback(() => <Text>{state}</Text>, [state]);

なんですけど、useCallbackじゃなくてuseMemoではないでしょうか?
趣旨を把握するに、useMemoInnerComponentという内部コンポーネントを作るという例みたいですので。

加えて直後の

const renderInner = useCallback(() => <Text>{state}</Text>, [state]);

とやっていることが全く同じですし。

ta21costa21cos

コメントありがとうございます。
私の理解している範囲で返信させていただきますね。

まず、この記事の内容の範囲においては、useMemouseCallback かはあまり違いがありません。
私の簡易的な理解としては、useMemouseCallback の違いはその場で評価されるかされないかという点です。
useMemo(() => state * 2, [state]) は渡した関数である () => state * 2 がその場で評価されて state * 2 に相当する値が返りますが、useCallback(() => state * 2, [state]) は渡した関数、すなわち () => state * 2 が返り値になります。
なので今回の例に当てはめると

const InnerComponent = useCallback(() => <Text>{state}</Text>, [state]);

const InnerComponent = useMemo(() => () => <Text>{state}</Text>, [state]);

は同じ意味になります(厳密には違うかもしれません)。useMemo では関数を返す関数を渡している点に注意してください。

質問に戻りますと、InnerComponentuseCallback でなく useMemo ではないかというご指摘でしたが、これは上の例のように自由に読み替えていただいて大丈夫というのが回答となります。
一番最初に示したコードでは useMemo を使っていますが、今思えばこれはどちらかというと useCallback を使うのが自然だと思っています(コンポーネントは関数なので、関数を記録する useCallback を使うのが自然)。
実際にあったコードをそのまま載せたため、少し紛らわしくなってしまいました。

本文で触れきれなかった点を質問頂きありがとうございました。

YAMAMOTO YujiYAMAMOTO Yuji

なるほど。すみません、後半ちゃんと読めてなくて私が少し勘違いしてたようです。ポイントはあくまで<Component .. />として使うか{式}として使うかの違いなんですね。