はじめに
は〜いこんにちは〜
どうもおでこです。よろしくお願いします。
この記事は、実際に起きた実務中の悲劇が元になっています。
自分で書いたコードでバグを生んでしまい、先輩の力もお借りし、壮絶なデバッグを経てなんとか解決できたけど、めっちゃ時間かかってしまった…というものです。
Reactむじぃ〜〜〜〜〜〜 と思っていたら、まさかのReact公式ドキュメントの落とし穴にも記載されている内容でした。初歩的なミスに引っかかってしまったというわけですね!!!ありがたいねぇ!!!
二度と同じミスをしないように、戒めとして記事に残したいと思います。
この記事で伝えたいことは1つです。これだけ覚えて帰ってください。
関数コンポーネントの中で関数コンポーネントを定義してはいけません!!!
自分で産んだ悲劇のバグ
まずはバグの内容を紹介します。
1言で表すと 「意図せずにコンポーネントがアンマウントされてしまうバグ」 です。
実装したのは、3種類のタブによって表示するコンポーネントを切り替えるUIでした。
このコンポーネントとは別に、画面全体で管理しているstateを1つ定義し、描画しています。便宜上、Add App Countボタンとカウンターを表示しており、クリックするとカウントが1増えます。
まず、タブをTabA
からTabC
に切り替えてみてください。
Add App Count
は画面全体で管理しているstateなので、タブを切り替えたとしてもカウンターの数値は引き継がれます。
さて、問題はここからです。
useEffect(() => {
alert("B component mounted");
}, []);
BComponentでは上記のようなuseEffectが定義されています。実際の業務ではこのuseEffectの中でfetchする処理を行っていますが、便宜上アラートを表示しています。
依存配列が空なので、BComponentが初めて画面に描画されたタイミング(タブを切り替えたとき)で、1度だけアラートが表示されるはずです。
では、タブをTabC
からTabB
に切り替えてみてください。
アラートが2回表示されますね🤔
何やら不穏が漂ってきました…
BComponentには、BComponent内で定義しているカウンターを表示しています。
Add B Count
ボタンをクリックすると、カウントが1増えます。
ここで、Add App Count
をクリックするとどうなるでしょうか?
理想は、Add App Count
のカウンターの数値だけが1増えることです。しかし、実際にクリックしてみると、タブを切り替えていないのに再びアラートが2回表示されます。さらに、Add B Count
のカウンターの数字がリセットされてしまいます。
バグの原因
起きているバグから以下のような仮説が立ちました。
タブBに切り替えたとき、BComponent
で定義しているuseEffect
が2回発火、かつカウンターの数値がリセットされてしまう
=> BComponent
が何かしらの理由で1度アンマウントされているのではないか
この仮説のもと、BComponent
をレンダリングしている箇所を確認してみます。
export default function App() {
const [type, setType] = useState<TabType>("A");
const [count, setCount] = useState(0);
const Components: FC = () => {
switch (type) {
case "A":
return <AComponent />;
case "B":
return <BComponent />;
case "C":
return <CComponent />;
}
};
return (
<div className="App">
// ...省略
<div className="container">
<Components />
</div>
<h2>{count}</h2>
<button onClick={() => setCount(count + 1)}>Add App Count</button>
</div>
);
}
このコードを見ると、Appコンポーネント内でComponents
という関数を定義し、それをレンダリングしていますね。実はこれが問題の原因です。
「ってお前、関数コンポーネントの中で関数コンポーネントを定義しとるやないかい!!!!!!!」
と今なら突っ込めるのですが、このコードを書いてデバッグ地獄にハマっているときは中々気づけませんでした。
なぜ、この書き方ではタブを切り替えたとき、一度コンポーネントがアンマウントされてしまうのでしょうか?
関数コンポーネントの中で関数コンポーネントを定義してはいけない理由
関数コンポーネントの中で関数コンポーネントを定義すると、再レンダリングが起こるたびに関数コンポーネントが再定義されてしまうため、バグが発生することがあります。
今回の例で考えてみます。
上記のコードで、以下の操作を行うと問題が発生しました。
- タブを切り替えると、
type
が変更されるため、Appコンポーネントが再レンダリングされます。 -
Add App Count
ボタンをクリックしてcount
が変更されるときも、Appコンポーネントが再レンダリングされます。
このとき、Appコンポーネント内で定義しているComponents関数も再定義されます。つまり、レンダリングのたびに新しいComponents関数が作られることになります。見た目には同じものが表示されているように見えますが、内部的には全く別の関数コンポーネントです。
レンダリングする<Components />
はレンダリングのたびに別物になるため、これまで表示していたBComponent
は一度アンマウントされ、再度レンダリングされます。そのため、以下のようなバグが発生します。
- BComponentのuseEffectが再度実行される。
- カウンターの状態がリセットされる。
React公式ドキュメントの落とし穴にも記載されている
この内容は、React公式ドキュメントにも記載されています。
同じ位置に異なるコンポーネントをレンダーしているので、React はそれより下のすべての state をリセットします。これはバグやパフォーマンスの問題につながります。この問題を避けるために、常にコンポーネント関数はトップレベルで宣言し、定義をネストしないようにしてください。
やりがちなミスということでしょうか…
まんまとハマってしまいました😭
終わりに
このバグにハマってしまったせいで、予定工数よりも大幅に遅れてしまいました(´・ω・`)
今回はuseEffectでアラートを表示するだけでしたが、実際にはAPIコールする結構重めの非同期処理が関わっていました。タブを切り替えたり、カウンターが変わるたびに余分にコールしてしまうと、パフォーマンスが落ちてしまいます。
しっかりと気づいて修正できたので良かったですが、もう二度と同じミスをしないようにしたいです💡
最後に戒めとしてもう一度。
関数コンポーネントの中で関数コンポーネントを定義してはいけません!!!
ありがとうございました。