🪞

Reactにおける<Component />とComponent()の違い

に公開

Reactでは、JSXを返す関数を組み合わせてアプリケーションを作っていきます。それらの関数は<Component />のように呼び出すこともあれば、Component()のように呼び出すこともあります。後者を使用する機会は多くないかもしれませんが、例えばrender hooksパターンによってカスタムフックからUIを返したいときに使われることがあります。

一見どちらも同じようにJSXを返しますが、Reactはそれらをまったく異なるものとして扱います。
この投稿では、2つの呼び出し方の違いと、それによって挙動がどう変わるのかを見ていきます。

「コンポーネント呼び出し」と「関数呼び出し」

ここでは、JSXを返す関数をJSX関数と呼びます。
JSX関数を<Component />と呼び出すことをコンポーネント呼び出しComponent()のように呼び出すことを関数呼び出しとします。また、コンポーネント呼び出しによって呼び出されるJSX関数や、その実行結果としてReactで管理される単位のことをコンポーネントと呼びます。

JSX関数はただの関数で、好きにネストしたり代入することができますが、コンポーネント呼び出しを行う場合、その関数をネストすることは推奨されていません。推奨されていない理由は後述します。

const Component = () => {
  const Inner = () => {
    return <div>Inner</div>;
  };

  return (
    <div>
      Component
      <Inner />
    </div>
  );
};

const ComponentB = Component;

export const App = () => {
  return (
    <>
      {Component()}
      <Component />
      <ComponentB />
    </>
  );
};

コンポーネント呼び出しの場合、タグが小文字から始まっていると組み込みのHTMLタグとして扱われてしまうため、先頭は大文字にするか、ドット記法を使用する必要があります。ドット記法を使用する場合は先頭を大文字にする必要はなく、例えばmotionというアニメーションのためのライブラリでは、<motion.div />のようなコンポーネントが使われています。

呼び出し方による挙動の違い

では、コンポーネント呼び出しと関数呼び出しで挙動が変わる状況について見ていきます。実行できるコードを提示したあとに挙動について説明します。

https://codesandbox.io/p/sandbox/r26dxs

挙動を確認するための検証コード

まず、初回のレンダリング処理が重くなるコンポーネントを用意します。初回のレンダリングではメモ化などの最適化がまだ効かないため、処理が重くなりやすいです。また、マウント時には追加で様々な処理が行われるため、さらに時間がかかることもあります。

const SlowMount = () => {
  const firstRender = useRef(true);

  if (firstRender.current) {
    for (let i = 0; i < 50000; i++) {
      console.log("delay");
    }
    firstRender.current = false;
  }

  return <div />;
};

このコンポーネントを使用したとき、コンポーネント呼び出しと関数呼び出しの挙動に違いがでてきます。挙動の確認には以下のようなコードを使用します。

export const App = () => {
  const [isFunctionCall, setIsFunctionCall] = useState(true);
  const [counter, setCounter] = useState(0);

  const Counter = () => {
    return (
      <div>
        <SlowMount />
        <button
          onClick={() => {
            setCounter((c) => c + 1);
          }}
        >
          +
        </button>
        <div>{counter}</div>
      </div>
    );
  };

  return (
    <div>
      <div>
        {isFunctionCall ? "関数呼び出し" : "コンポーネント呼び出し"}
        <button
          onClick={() => {
            setIsFunctionCall((f) => !f);
          }}
        >
          切り替える
        </button>
      </div>
      {isFunctionCall ? Counter() : <Counter />}
    </div>
  );
};

このコードは、SlowMountコンポーネントを内部で利用するCounterを表示するものです。Appコンポーネントはカウンターの状態を管理しており、Counter関数はその状態を表示・更新します。また、Appコンポーネント内のisFunctionCallという状態を切り替えることで、Counter関数をコンポーネント呼び出しと関数呼び出しのどちらの形式で呼び出すかを切り替えることができます。

コンポーネント呼び出しで起きる遅延の正体

何度か+ボタンを押してみると、関数呼び出しと比べてコンポーネント呼び出しはカウントが更新されるまでに遅延があります。+ボタンを押すたびにconsole.logが表示されていることから、SlowMountコンポーネントが再レンダリングではなく、アンマウントされてからマウントが実行されていることがわかります。

この原因は、Counter関数がAppコンポーネントのレンダリングのたびに異なる関数として作成され、別のコンポーネントとして扱われるからです。別のコンポーネントとして扱われているので、状態が変更されたとき、再レンダリングではなくアンマウントとマウントが発生します。具体的には以下のような流れで処理されます。

  1. setCounterが呼び出されるとAppが再レンダリングされる
  2. 再レンダリング時に新しいCounter関数が作成される
  3. Reactは前回のレンダリングで使用されたCounterとは別のコンポーネントと認識する
  4. 前回のCounterがアンマウントされ、新しいCounterがマウントされる

このように、Counter関数が毎回新しく生成されることで、レンダリングのたびにアンマウントとマウントが発生します。Counterコンポーネントがアンマウントされると、含まれるSlowMountコンポーネントもアンマウントされ、マウント時に重たい処理が走ってしまいます。一見するとCounterは同じコンポーネントなのですが、Reactは別のコンポーネントとして扱います。

ReactではuseCallbackを使って関数をメモ化することもできますが、今回のケースだと依存リストにcounterを含める必要があるので、counterを変更すると結局マウントが発生することになります。また、SlowMountReact.memoを使用しても親のCounterがアンマウントされるので意味がないですし、Counter関数にReact.memoを使用しても、何度もReact.memoが呼ばれるだけなので異なる関数が返ってきます。

コンポーネントが状態を持っている場合、アンマウントとマウントによってリセットされるという問題もあるため、コンポーネントの中でコンポーネントを定義することは推奨されていません。

なぜ関数呼び出しでは遅延が起きないのか

ここまでで、レンダリングのたびに新しいCounter関数が作成されるため、Reactが別のコンポーネントだと認識してしまい、アンマウントとマウントが発生することがわかりました。

しかし、Counter関数はレンダリングのたびに異なっているにもかかわらず、関数呼出しではSlowMountのアンマウントが起きていません。

これは、マウントや再レンダリング、アンマウントといったライフサイクルがコンポーネントに固有の仕組みだからです。関数呼び出しではコンポーネントが作成されないため、Reactはライフサイクルを管理しません。SlowMountがアンマウントされていたのは、親であるCounterコンポーネントがアンマウントされていたためです。Counterがライフサイクルを持たない関数呼び出しで実行される場合、SlowMountもアンマウントされることはありません。

ReactはReact要素と呼ばれるプレーンなオブジェクトによってコンポーネントを認識してライフサイクルを管理します。JSXで<Component />のようにコンポーネント呼び出しを行うと、TypeScriptやBabelなどのツールによってreact/jsx-runtimejsx関数を使ったコードに変換されます。この関数はReact.createElementとほぼ同じ動作をし、最終的にReact要素を返します。ReactはこのReact要素をもとに、どの関数をコンポーネントとして扱うかを判断し、マウントやアンマウントといったライフサイクルを管理します。また、カスタムコンポーネントだけでなく、divpなどのHTMLタグからもReact要素が生成されます。

一方、関数呼び出しで呼び出された関数はReact要素を経由しないため、Reactはそれをコンポーネントとして扱いません。関数呼び出しを行っても、返り値のJSXが単に親のレンダリング結果に埋め込まれるだけで、関数自体はReactによって管理されず、ライフサイクルを持ちません。

Counterを関数呼び出しで実行すると、その返り値に含まれるSlowMountコンポーネントが直接展開されます。そのため、+ボタンを押してAppコンポーネントの状態を更新しても、Counter自体はアンマウントされず、SlowMountも再レンダリングされるだけです。SlowMountはトップレベルで定義されているため、レンダリングのたびに新しく作り直されないためです。

まとめ

JSX関数には大きく2つの呼び出し方があります。

  • <Component />のように呼び出すコンポーネント呼び出し
  • Component()のように呼び出す関数呼び出し

Reactはコンポーネント呼び出しによって生成されたReact要素をもとに、その関数をコンポーネントとして認識し、ライフサイクル(マウント、再レンダリング、アンマウント)を管理します。

一方、関数呼び出しではReact要素を経由しないため、その関数はコンポーネントとして扱われず、ライフサイクルの対象にもなりません。

参考資料

chot Inc. tech blog

Discussion