🐙

[React初心者🔰]Failed to execute 'removeChild'のエラーを解消した話

に公開

起きたこと

Reactで実装したページで、5回に1回くらいの頻度で以下エラーが発生していました。
Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

ソースコードは以下

export default function CompanyListTable({
  engineerId,
}: { engineerId: string }) {
  const [companyList, setCompanyList] = useState<companyType[]>([])
  const [blockedMessage, setBlockedMessage] = useState<string>('')
  
  // 企業のリストを取得
  useEffect(() => {
    return LtsfRequest.support(async (request) => {
      const response = await request.get(
        `/get_company_list/${engineerId}`,
      )
      setCompanyList(response.data.company_list)
    })
  }, [engineerId])

  // ブロックメッセージを取得
  useEffect(() => {
    return LtsfRequest.support(async (request) => {
      const response = await request.get(
        `/get_blocked_message/${engineerId}`,
      )
      setBlockedMessage(response.data.blocked_message)
    })
  }, [engineerId])

  // 企業リストのコンポーネント
  function CompanyListComponent({
    companyList,
  }: {
    companyList: companyType[]
  }) {
    return (
      <>
        {companyList.map(
          (
            company: companyType,
          ) => (
            <tr key={company.id}>
              <td>{company.id}</td>
              <td>{company.name}</td>
            </tr>
          ),
        )}
      </>
    )
  }

  // ブロックメッセージのコンポーネント
  function BlockedMessageComponent({
   blockedMessage,
 }: { blockedMessage: string }) {
    return (
      <div className="panel-body form">
        <TextArea
          text={blockedMessage}
        />
      </div>
    )
  }

  return (
    <>
      <CompanyListComponent
        companyList={companyList}
      />
      <BlockedMessageComponent
        blockedMessage={blockedMessage}
      />
    </>
  )
}

原因と修正内容

エラーの原因は、コンポーネント関数のネストでした。
CompanyListComponentとBlockedMessageComponentの定義がCompanyListTableの中で定義されていることが原因となっておりました。
修正後のソースコードは以下

+ // 企業リストのコンポーネント
+ function CompanyListComponent({
+   companyList,
+ }: {
+   companyList: companyType[]
+ }) {
+   return (
+     <>
+       {companyList.map(
+         (
+           company: companyType,
+         ) => (
+           <tr key={company.id}>
+             <td>{company.id}</td>
+             <td>{company.name}</td>
+           </tr>
+         ),
+       )}
+     </>
+   )
+ }

+ // ブロックメッセージのコンポーネント
+ function BlockedMessageComponent({
+  blockedMessage,
+ }: { blockedMessage: string }) {
+   return (
+     <div className="panel-body form">
+       <TextArea
+         text={blockedMessage}
+      />
+     </div>
+   )
+ }

export default function CompanyListTable({
  engineerId,
}: { engineerId: string }) {
  const [companyList, setCompanyList] = useState<companyType[]>([])
  const [blockedMessage, setBlockedMessage] = useState<string>('')

  // 企業のリストを取得
  useEffect(() => {
    return LtsfRequest.support(async (request) => {
      const response = await request.get(
        `/get_company_list/${engineerId}`,
      )
      setCompanyList(response.data.company_list)
    })
  }, [engineerId])

  // ブロックメッセージを取得
  useEffect(() => {
    return LtsfRequest.support(async (request) => {
      const response = await request.get(
        `/get_blocked_message/${engineerId}`,
      )
      setBlockedMessage(response.data.blocked_message)
    })
  }, [engineerId])

  return (
    <>
      <CompanyListComponent
        companyList={companyList}
      />
      <BlockedMessageComponent
        blockedMessage={blockedMessage}
      />
    </>
  )
}

CompanyListComponentとBlockedMessageComponentの定義位置をトップレベルに移動しました!
そうすることでエラーが解消しました🙆‍♀️

原因の詳細

AIに相談しつつ、エラーの原因の詳細を解説します。

Reactの特性の復習

⬛️Reactのレンダーの流れ
Reactは以下の流れでレンダーします

  1. レンダーのトリガ
    初回レンダーもしくはstateの更新によってレンダーがトリガされます
  2. コンポーネントのレンダー
    仮想DOMを生成します
  3. DOMへのコミット
    2の手順で生成した仮想DOMと以前の仮想DOMを比較し、差分に基づいてHTML DOMを更新します

ここでの注意はReactはレンダー間で違いがあった場合にのみDOMノードを変更するということ

⬛️Reactのコンポーネントの認識
ReactはUIツリーの、同じ位置にある同じ型の要素を同一コンポーネントと認識します。
例えば同じ位置のコンポーネントでも、<p>ほげ</p><div>ほげ</div>は別物と認識されます。

修正前のコードによるReactの動きを見てみる

CompanyListTable内にCompanyListComponent、BlockedMessageComponentが定義されているため、setBlockedMessage、setCompanyListによって再レンダーがトリガーされた際にこれらのコンポーネント関数はJavaScriptの新しい関数オブジェクトとして生成されます
つまりReactにとっては、setBlockedMessageとsetCompanyListは前回レンダー時のコンポーネントとは異なるコンポーネントと認識されることになります。

原因の詳細

レンダーの際にコンポーネント関数が再生成されることで、Reactによる仮想DOMの差分比較とDOM操作の際に混乱を招いたことが原因ではないかと予想。
CompanyListComponent、BlockedMessageComponentが同じコンポーネントであっても、Reactにとっては以前の仮想DOMと異なるオブジェクトと認識してしまい、不適切なDOM操作(removeChild)が発生してしまいエラーになったのではないか、という結論にいたりました。

補足

「コンポーネント関数の定義をネストしてはいけない」と、公式ドキュメントにも注意書きがありました。

一度読んでいても、実際にバグにあたってみないと頭に入らないものですね。

感想

エラーの原因がわかるまでかなり時間がかかってしまいました(毎回エラーになるわけではないということでまた時間がかかりました...)
また、Reactをデバッグするときはブラウザのキャッシュのことも考慮した方が良いです。
キャッシュクリアをせず確認していたら、エラーになるべきでないところでもエラーになってしまい、調査に無駄な時間を要してしまいました。

参考

Discussion