ReactでuseIdを使い一意な文字列を生成し汎用コンポーネントのid重複を防ぐ

はじめに

こんにちは WESEEK で yaml から css まで何でも書く haruhikonyan です。
フォームなどをコンポーネント化したときに同じページにそのコンポーネントを使うと id の重複に困ったりしませんか?
そんな時に React が公式で提供している useId という hook を使うと解決するかもしれません。
しかし利用においては注意点があるので具体例とともに紹介したいと思います。

useId とは

まず最初に useId とは何かです。
簡単に言えば同じ React を使ったアプリケーションの中で重複の無い id となる文字列を作り出すものです。

import { useId } from 'react';

const id = useId()
const id2 = useId()

console.log(`id1 => ${id1}, id2 => ${id2}`)
// id => :r1:, id2 => :r2:

こんな感じに : で囲われて数値がインクリメントされた文字列が生成され、重複がないことが保証されています。
くわしくは 公式ドキュメント を読んでいただければよいと思います。

具体的な使い方

具体的な使い方として公式の Usage の例を解説します。

import { useId } from 'react';

function PasswordField() {
  const passwordHintId = useId();
  return (
    <>
      <label>
        Password:
        <input
          type="password"
          aria-describedby={passwordHintId}
        />
      </label>
      <p id={passwordHintId}>
        The password should contain at least 18 characters
      </p>
    </>
  );
}

例に出てきた完成系のコードです

何がうれしいかというと
パスワードのインプットではあまり無いと思いますが、汎用化されたコンポーネントを同じページに2つ使う(2種類のパスワードを入力する必要がある)場合に、useId を使わずに固定の id を使ってしまうと、HTML 上に同じ 2つ以上の id が存在してしまい、HTML のルールを違反してしまうということが起こります。

useId で生成した文字列が selector として使えない!?

まずこちらのコンポーネントを見てください。
これはかなり簡略化されていますが、私が実際に最初に業務で書いた useId を用いたコードです。

import { useId } from 'react'
import { UncontrolledTooltip } from 'reactstrap'

const Tooltip = ({tooltipValue: string, tooltipLabel: string}) => {
  const id = useId()
  return (
    <>
      <UncontrolledTooltip target={id}>
        {tooltipValue}
      </UncontrolledTooltip>
      <span id={id}>
        {tooltipLabel}
      </span>
    </>
  )
}

UncontrolledTooltipchildren に渡した内容を target に指定した id の要素がホバーされた時に出てくるという reactstrap が提供するコンポーネントです。
このコンポーネントを使うと任意の場所にツールチップを設置することが可能です。(これだけ見ると UncontrolledTooltip をそのまま使えばいいと思うかもしれませんが、あくまで例としての提示です)
一応 useId を使っているので id の値は自分で決めなくても良いし必ず一致するという利点があります。

しかしこのコードはエラーが出ます。

Unhandled Runtime Error
SyntaxError: Failed to execute 'querySelectorAll' on 'Document': ':r1:' is not a valid selector.

ライブラリのコードを読み進めるとありました。
https://github.com/reactstrap/reactstrap/blob/master/src/utils.js#L293
けっこう深くまで潜ってますが、target に渡せた idquerySelectorAll に渡されています。
ドキュメント を参照しますとどうやら :標準の CSS の構文に従っていない ものであるようなのでエスケープしてあげろと書かれています。

エスケープしたコンポーネントがこちらです。

import { useId } from 'react'
import { UncontrolledTooltip } from 'reactstrap'

const Tooltip = ({tooltipValue: string, tooltipLabel: string}) => {
  const id = useId()
  return (
    <>
        {/* Tooltip が内部で使ってる querySelector には useId で付与される : が使えずエラーが出るため escape する
        see: https://stackoverflow.com/a/75178117 */}
      <UncontrolledTooltip target={CSS.escape(id)}>
        {tooltipValue}
      </UncontrolledTooltip>
      <span id={id}>
        {tooltipLabel}
      </span>
    </>
  )
}

このように CSS クラスが用意している escape 関数を使えば適切に : をエスケープしてくれて使えるようになります。

おまけ

useId に : を追加した経緯など

https://github.com/facebook/react/pull/23360
詳しくは議論を読んでもらえばと思いますが、
コメントでは強気に

there are workarounds or alternative solutions.

と言っていてけっこう大胆に思いつつも、かくいう私も同意なんでおもしろいなと思いました。
React の世界観というのはいわゆる JQuery や素の js でやりがちな selector をセットしてそれに対して何か作用を加えるということを廃し、宣言的にロジックを書いていくものであるため querySelector のようなものはライブラリ側としては考慮しなくていいというのは良くも悪くも思想がはっきりと伝わってくるなと思いました。

終わりに

複数個所で使うようなコンポーネントを共通化して使い回すのはいいぞ!