MIXI DEVELOPERS Advent Calendar 2022 17日目の記事です。
はじめに
仕事でReactを使ってWebアプリを開発しています。
突然ですが皆さんはReact初心者のPRをレビューしているときに、こんな構造を見たことはないでしょうか。
const ParentComponent = () => {
const ChildComponent = () => {
return <p>これはParentComponent内で定義されたChildComponentです</p>
}
return <ChildComponent />
}
コンポーネント内に他のコンポーネントを定義しています。
なんとなくパフォーマンス的によくなくてアンチパターンであるというのはどこかで見かけて知っていたので、以前PRで遭遇したときには根拠となる技術ブログを漁ってリンクを貼りつつ、「パフォーマンスが落ちるようなアンチパターンだから避けたほうがよさそうです」とコメントして納得してもらった記憶があります。
ただもうちょっと何が悪いのかちゃんと理解したい気持ちが残っていたので、今回ちゃんとコードを書いて実験しながら調べてみました。
アンチパターンな理由
こちらの記事がとてもわかりやすかったので参考にしています。
コンポーネント内に他のコンポーネントを定義するのがアンチパターンになっている理由として、記事にはこのように書かれてあります。
Creating components inside render function of another component is an anti-pattern that can be the biggest performance killer. On every re-render React will re-mount this component (i.e. destroy it and re-create it from scratch), which is going to be much slower than a normal re-render.
親のコンポーネントが再描画されるたびに子コンポーネントが再描画ではなくて一から再生成されてしまい、描画が遅くなってしまうらしいです。
💡Reactコンポーネントのライフサイクル
再描画や再生成とはどういうことなのでしょうか。
Reactコンポーネントのライフサイクルは大きく三つの流れに分けられます。
1.マウント -> 2.更新 -> 3.アンマウント
再描画はマウント済みのコンポーネントの更新が走ることで、
再生成とはマウント済みのコンポーネントが一度アンマウントされたのちに再度新しくマウントされることを意味しています。
再生成されることの弊害たち☠️
先ほどの記事には、コンポーネントが再生成されてしまう弊害についてこのような説明があります。
On top of that, this will lead to such bugs as:
・possible “flashes” of content during re-renders
・state being reset in the component with every re-render
・useEffect with no dependencies triggered on every re-render
・if a component was focused, focus will be lost
これらの弊害についてサンプルアプリを作成し、外に定義したときと比較して一個ずつ実際に確認していきたいと思います。
ここでは便宜上、親のコンポーネントの中で定義した子コンポーネントを内側のコンポーネント、外で定義した子コンポーネントを外側のコンポーネントと呼ぶことにします。
用意したサンプルアプリのコードはこちらになります。
import "./styles.css";
import { useEffect, useState } from "react";
const ComponentOutsideApp = () => {
const [childOutsideInput, setChildOutsideInput] = useState("");
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://senbeiman.github.io/react-component-in-component-demo/api/outside.json");
const data = await response.json();
setData(data);
return setTimeout(() => {
setChildOutsideInput(data.name);
}, 1000);
};
const timeoutId = fetchData();
return () => {
clearTimeout(timeoutId);
};
}, []);
return (
<div className="childOutside">
<p>外側のコンポーネント</p>
<div>
<img
src={data?.imageUrl}
alt=""
/>
</div>
<input
onChange={(e) => {
setChildOutsideInput(e.target.value);
}}
value={childOutsideInput}
/>
</div>
);
};
const App = () => {
const [parentCount, setParentCount] = useState(0);
const ComponentInsideApp = () => {
const [childInsideInput, setChildInsideInput] = useState("");
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://senbeiman.github.io/react-component-in-component-demo/api/inside.json");
const data = await response.json();
setData(data);
return setTimeout(() => {
setChildInsideInput(data.name);
}, 1000);
};
const timeoutId = fetchData();
return () => {
clearTimeout(timeoutId);
};
}, []);
return (
<div className="childInside">
<p>内側のコンポーネント</p>
<div>
<img
src={data?.imageUrl}
alt=""
/>
</div>
<input
onChange={(e) => {
setChildInsideInput(e.target.value);
}}
value={childInsideInput}
/>
</div>
);
};
return (
<div className="parent">
<p>親コンポーネント</p>
<button
onClick={() => {
setParentCount(parentCount + 1);
}}
>
再描画
</button>
<button
onClick={() => {
setTimeout(() => {
setParentCount(parentCount + 1);
}, 3000);
}}
>
3秒後に再描画
</button>
<div className="flex">
<ComponentInsideApp />
<ComponentOutsideApp />
</div>
</div>
);
};
export default App;
CodeSandboxも用意したので是非実際に触ってみてください。
画像取得APIを擬似的に再現するため、GitHub Pagesに自作画像とそのURLを参照するjsonデータを置いてfetchしています。
親コンポーネントがカウンターのstateを保持しており、「再描画」ボタンを押すとそのstateを更新することで親コンポーネントの再描画が発生します。
内側のコンポーネントと外側のコンポーネントの違いはAPIのエンドポイントの画像と入力欄にデフォルトで入る名前の違いのみになります。
このとき、内側のコンポーネントと外側のコンポーネントの挙動が以下のように変わり、弊害が見えてきます。
弊害1. stateがリセットされてしまう
内側のコンポーネントと外側のコンポーネントのそれぞれで入力欄の値をstateで持たせています。
stateの初期値は空文字にしています。
ここで、再描画ボタンを押すと内側のコンポーネントは入力欄の値が空になります。
一方、外側のコンポーネントは入力欄の変化がありません。
親コンポーネントが再描画されるたびに子コンポーネントがマウントされ直され、そのときにstateがリセットされてしまうのがわかります。
弊害2. useEffectが毎回発火する
入力欄を適当に編集してから「再描画」ボタンを押すと、1秒後に入力欄の値がuseEffect内でAPIを叩いて取得した名前に変わる処理を入れています。
内側のコンポーネントはボタンを押すたびに1のstateのリセットで入力欄が空になった1秒後に再び名前が入っています。
一方、外側のコンポーネントは入力欄に変化がありません。
useEffectの第二引数に空行列を渡すとマウント後の初回描画時のみ実行されますが、内側のコンポーネントは親コンポーネントが再描画されるたびにマウントされ直すため、毎回実行されてしまいます。
弊害3. ちらつきが発生しうる
「再描画」ボタンを押すと左側の画像だけちらついているのがわかります。
サンプルアプリではuseEffectのマウント後の初回描画の処理の中で、擬似APIを叩いて取得したデータをstateにセットし、画像のURLを参照しています。内側コンポーネントは毎回APIへリクエストを送り、取得が完了するまではデータのstateがnullのため、その間は画像が表示されず、ちらつく原因となっています。
今回は結局1と2の現象に起因するのですが、他にも単純に初期状態を計算するときの処理が重かったりすると同じようにちらつきが発生すると思われます。
弊害4. フォーカスが外れてしまう
親コンポーネントの再描画を3秒後に引き起こす「3秒後に再描画」ボタンも用意しました。
このボタンを押した後、3秒以内に入力欄を選択してフォーカスを当てた状態で待つと、内側のコンポーネントだけ再描画のタイミングでフォーカスが外れてしまいます。
一方、外側のコンポーネントは親コンポーネントが再描画されてもフォーカスが当たったままです。
内側のコンポーネントの場合、入力欄のinputコンポーネントが一度アンマウントされて再度マウントされ直すため、フォーカスが維持されません。
まとめ: コンポーネント内にコンポーネントを定義したらダメ🙅
stateやuseEffectなどReactの良さを活かしたければコンポーネント内にコンポーネントを定義するメリットはなさそうに思いました。あえて使うような場面も思いつかなかったのですが、もしあればぜひ教えてください。
また、これらの弊害がコンポーネント内でのコンポーネント定義によって引き起こされることを知っておくとデバッグ時に役立つかもしれません。
今回はかなり初歩的な話でしたが、useMemo
など、もう少しややこしい話もサンプルアプリを作りながら確認していけると理解が深まりそうに思いました。CodeSandbox初めて使ったのですがいきなりほぼセットアップ不要でReact使えて便利ですね!シリーズ化を目指したいです。