React の useOpaqueIdentifier について

React, JavaScript, Accessibility

この記事の内容は古いです

この記事に書かれていた useOpaqueIdentifier は、React 18 から useId Hooks として正式にリリースされました。

前書き

React の SSR 向けに Opaque ID を生成する useOpaqueIdentifier Hooks について覚書。
この記事で書かれているのは、実験的な Hooks で、仕様が変わるかもしれない。
デモはこちら

ID参照と Hydration の問題

マークアップでは、ある要素が別の要素を参照する時、label要素の for 属性や aria-labelledby などの ARIA 属性を用いる。その属性には、参照する要素の ID を指定する必要があり、ID を持った要素が DOM 上になければならない。

html

<!-- id が inputFoo の要素を参照している label -->
<label for="inputFoo">Foo</label>

<!-- id に inputFoo を持つ要素が DOM にないと label はエラー -->
<input id="inputFoo" type="text" />

今回は、そのようなものを React コンポーネントで実装する時のお話。

InputField.tsx

import * as React from 'react'

type Props = {
  label: string
  handleChangeText: (e: React.ChangeEvent<HTMLInputElement>) => void
}

export const InputSample: React.FC<Props> = props => {
  return (
    <>
      <label htmlFor="inputSample">{props.label}</label>
      <input id="inputSample" name="inputSample" type="text" />
    </>
  )
}

上記の InputSample が 2 つあると、inputSample の ID も 2 つ文書に存在することになる。
ページ内のIDはユニークでなければならないため、 ID の重複を防ぐ必要がある。

簡単なものとして、UID を Props なりで渡し、プレフィックス的に用いる方法がある。
しかし、ID 参照のための ID を管理する、悲しみの歴史を繰り返すことになる。

pages/index.tsx

{/* どうして uid を渡さないといけないんですか? */}
<InputSample uid={'input1'} />

コンポーネント側で生成する時の懸念

ID 参照のための UID を利用者が設定するのではなく、いっそのこと自動で設定されるのはどうだろう。
コンポーネントが UID が生成してくれれば、それに越したことはないため。

しかし、SSR では、この UID の生成方法について懸念があった。
サーバー側で生成された UID と、Hydration 時の UID が一致しないケースが考えられるから。
Hydration では、属性の値が一致しない時に修正される保証はないので、ID 参照に依存するコンポーネントを作成するには無視できない問題。

例えば、コンポーネント内部で uuid を生成した時の挙動を見てみる。
下記では uuid を生成して、htmlForid 属性にそれぞれ結び付けている。

InputField.tsx

/* ----------省略---------- */
export const InputSample: React.FC<Props> = props => {
  const uid = React.useMemo(() => uuid.v4(), [])
  return (
    <>
      <label htmlFor={uid}>{props.label}</label>
      <input id={uid} name="inputSample" type="text" />
    </>
  )
}

しかし、サーバー/クライアントで uid の戻り値が一致しないため、警告が表示される。

サーバー側で生成されるUIDと、クライアント側で生成されるUIDが異なるので、開発者メニューで警告が出る

下記のようなインクリメントだとどうなるのか。
クライアントがカウントをリセットしても、サーバーではカウントされ続けるので、結局は不一致が生じる。

InputField.tsx

import * as React from 'react'

let count = 0 // カウント

export const InputSample: React.FC<Props> = props => {
  const uid = `uid_${count++}` // リロードでずれる

  return (
    <>
      <label htmlFor={uid}>{props.label}</label>
      <input id={uid} name="inputSample" type="text" />
    </>
  )
}

downshift では、インクリメントの問題に対してコンポーネントにidを渡す方法で対処しているが、これは結局、最初の項目で挙げたような UID 管理の問題。
コンポーネントの使用者を混乱させ、 Issue が立ってしまうかも。

react-uid は、 Context API から ID を流し込む手法を取り入れている。
専用の Consumer で囲めば、サーバー/クライアント間で一貫した UID が生成される。
しかし、結局この方法も、開発者が UID を生成するための Wrapper で囲むことを強いられてしまう。

Server-side_friendly_UID

import {UIDReset, UIDConsumer} from 'react-uid';

<UIDReset>
    <UIDConsumer>
        {(id,uid) => (
          <Fragment>
            <input id={id} />
            <label htmlFor={id} />
            data.map( item => <li key={uid(item)}>{item}</li>)
          </Fragment> 
        )}
    </UIDConsumer>
</UIDReset>

このような背景もあってか「React 側で UID 生成できればいいよね」問題が以前から議論されていた。
https://github.com/facebook/react/issues/5867

そこから派生して RFC も出ていた。
https://github.com/reactjs/rfcs/pull/32

そして、useOpaqueIdentifier の PR がマージされた。
https://github.com/facebook/react/pull/17322

useOpaqueIdentifier とは

useOpaqueIdentifierは、Hydration 時の不一致を起こさない UID を生成する Hooks となる。
現在、この Hooks は experimental なので、使用する場合は experimental build をインストールする。

shell

npm install react@experimental react-dom@experimental

使用方法はシンプルで、React.unstable_useOpaqueIdentifier() を呼びだすだけ。

foo.tsx


const Foo: React.FC = (props) => {
// @ts-ignore
const uid = React.unstable_useOpaqueIdentifier()

return <p id={uid}>props.children</p>
}

id 属性に入っている uid の値を確認すると、r:0という文字列が入っていることがわかる。
0の部分は 36 進数で表され、コンポーネントが追加されたり、Route が切り替わるたびに更新される。
そして、リロードすると連番が両方とも 0 に戻るので、先に挙げたカウンターの不一致も防げる。

何をしてるの: 文字列の生成

挙動を見ると、r:0から始まる一貫した UID を生成する Hooks のように思える。
しかし、この Hooks の役割は、Hydration 時に属性を更新し、再レンダリングを試みること。実際に生成される ID 自体は、サーバーとクライアントで別にある。

まず、サーバー側では R: を含む何らかのプレフィックスと、インクリメントされた UID の結果を返す。
プレフィックスがあるのは、複数の SSR が混在することを想定しての仕様だと思われる。

packages/react-dom/src/server/ReactPartialRendererHooks.js

function useOpaqueIdentifier(): OpaqueIDType {
  return (
    (currentPartialRenderer.identifierPrefix || '') +
    'R:' +
    (currentPartialRenderer.uniqueID++).toString(36)
  );
}

クライアント側では、似たような処理が ReactDOMmakeClientId にある。
やっていることはサーバー側と同じだけど、プレフィックスは小文字の r: のみで、サーバーの出力とは異なることがわかる。

packages/react-dom/src/client/ReactDOMHostConfig.js

let clientId: number = 0;
export function makeClientId(): OpaqueIDType {
  return 'r:' + (clientId++).toString(36);
}

つまり生成時点では、r: でなく R: 形式の文字列が ID や ARIA 属性に指定されている。
ブラウザの設定で JavaScript を無効化すると確認できる。

サーバー/クライアント間のプレフィックスはあえて合わせていない。
SSR と CSR が混在するケースで、ID の衝突を防ぐためだそう。

何をしてるの: OpaqueHydratingObject と Hooks

先ほどは UID の文字列を生成する処理を見たけど、実際にはサーバーとクライアントでそれぞれ別の ID を生成していることがわかった。
次は、 Hydration 時に生成される OpaqueHydratingObject を見てから、Hooks の処理を調べる。

まず、 Hydration 時には、単なる UID の文字列ではなく、OpaqueHydratingObject なるものを生成する。
このオブジェクトは React の要素の 1 つであることを示す $$typeof Symbol を持ち、toStringvalueOf をオーバーライドしている。
Object の toStringvalueOf は、文字列へ変換される時などに呼び出されるものだけど、OpaqueHydratingObject では代わりに attemptToReadValue が発火する仕掛けとなっている。

packages/react-dom/src/client/ReactDOMHostConfig.js

export function makeOpaqueHydratingObject(
  attemptToReadValue: () => void,
): OpaqueIDType {
  return {
    $$typeof: REACT_OPAQUE_ID_TYPE,
    toString: attemptToReadValue,
    valueOf: attemptToReadValue,
  };
}

次は Hooks FibermountOpaqueIdentifier
長いので、ちょっとずつ見ていく。

まず最初の makeId は、r:0 などの文字列を生成する makeClientId 関数。
Hydration でない時は、この文字列がそのまま mountState(id) されて、 return される。
mountState(id)React.useState(id)と同じ役割。

ReactFiberHooks.new.js

// ここで生成
const makeId = __DEV__
  ? makeClientIdInDEV.bind(
      null,
      warnOnOpaqueIdentifierAccessInDEV.bind(null, currentlyRenderingFiber),
    )
  : makeClientId;
    
if (getIsHydrating()) {
  /* --------省略-------- */
} else {
  // `makeClientId` をセットして return する
  const id = makeId();
  mountState(id);
  return id;
}

Hydration 時はどうなるのか。
まず readValue なる関数式が出てくるので、その中身を見ていく。

ReactFiberHooks.new.js

if (getIsHydrating()) {
  let didUpgrade = false;
  const fiber = currentlyRenderingFiber;
  const readValue = () => {
    if (!didUpgrade) {
      // Only upgrade once. This works even inside the render phase because
      // the update is added to a shared queue, which outlasts the
      // in-progress render.
      didUpgrade = true;
      if (__DEV__) {
      isUpdatingOpaqueValueInRenderPhase = true;
      setId(makeId());
      isUpdatingOpaqueValueInRenderPhase = false;
      warnOnOpaqueIdentifierAccessInDEV(fiber);
      } else {
        setId(makeId());
      }
    }
    // throw する
    invariant(
      false,
      'The object passed back from useOpaqueIdentifier is meant to be ' +
      'passed through to attributes only. Do not read the value directly.',
    );
  };
  /* 続く */

invariant は次のセクションで説明するが throw Error で、不正な Hooks の使用を防ぐためにある。
setId(makeId())についても後述する。

その後、話題の OpaqueHydratingObject を生成する。引数に渡すのは readValue

ReactFiberHooks.new.js

  /* 上のコードの続き */

  // OpaqueHydratingObject
  const id = makeOpaqueHydratingObject(readValue);

  // setState
  const setId = mountState(id)[1];
  
  // Legacy Mode なら useEffect のような操作をして setID を実行
  if ((currentlyRenderingFiber.mode & BlockingMode) === NoMode) {
    currentlyRenderingFiber.effectTag |= UpdateEffect | PassiveEffect;
    pushEffect(
      HookHasEffect | HookPassive,
      () => {
        setId(makeId());
      },
      undefined,
      null,
    );
  }
  return id;
}

setIdreadValue でも出てきたものだけど、mountStateの dispatch 、要するに useState の setState にあたる。
OpaqueHydratingObject を id として return する。

オマケで return 前のビット演算については、レンダリングが BlockingMode (Concurrent Mode への移行向けモード)以上の新しい mode でない場合は Legacy Mode とみなし、useEffect 相当の操作を行うらしい。

なんで OpaqueHydratingObject なの

長々と書いたけど、やることは UID の文字列か OpaqueHydratingObject をセットするだけ。
なぜ Hydration 時は文字列でなく、OpaqueHydratingObject なのか。

ひとつは、Hydration 中の ID を、文字列ではなく Opaque ポインタのように扱う狙いがあるのだと思われる。
OpaqueHydratingObject は、バリデーション時にサーバーから渡される ID さえも上書きしていて、DEV 環境で Hydration 不一致の警告を出さない理由にもなっている。
本当に必要な ID は再レンダリング後のものなので、readValueなどで直接参照するまでは隠蔽するのだと思っている。

この方法は id 属性の値に意味を持たせる必要がないからできること。
連番の情報も必要だと、生成順が保証されないこの方法は厳しそう。
例えば Concurrent Mode では複数のコンポーネントが Suspend されたり、ネストされたコンポーネントがさらに Suspend されていることも考えられる。
Hydration も様々で、部分的な Hydration もあれば、将来的には Progressive Hydration もある。
今回のケースでは、完璧な ID を生成する魔法を考えるより、ID の生成と再レンダリング処理を押し込めて、随時置き換えさせる試みの方が、現実的に思える(思えてきた)。

もうひとつは、この Hooks が「ID 参照」の問題を解決することにユースケースを絞っているから。
あくまでアクセシビリティ対応のためにあり、sessionID の生成などに使うのを意図していない。
実際にこの Hooks は、属性の値を設定する以外の用途で使うことを禁じている。
例えば先の項目で触れた invariant は、JSX 内で値が不正に読み込まれていれば、readValue経由で throw される。

packages/react-dom/src/client/ReactDOMHostConfig.js

invariant(
  false,
  'The object passed back from useOpaqueIdentifier is meant to be ' +
  'passed through to attributes only. Do not read the value directly.',
);

そうでなくとも makeClientId には DEV 環境限定でデバッグ用の関数が入っており、開発者が文字列化させようとすると警告が出るので、警告さえ読まれれば、間違った用途で使われることはない。

packages/react-dom/src/client/ReactDOMHostConfig.js

export function makeClientIdInDEV(warnOnAccessInDEV: () => void): OpaqueIDType {
  const id = 'r:' + (clientId++).toString(36);
  return {
    // 勝手に文字列にしたら怒られる
    toString() {
      warnOnAccessInDEV();
      return id;
    },
    valueOf() {
      warnOnAccessInDEV();
      return id;
    },
  };
}

もうひとつ、オブジェクトの toString() を実行させて間接的に throw させ、再レンダリングを狙うような実装も見られた。 ReactDOMComponentdiffProperties では、nextPropがこの $$typeof かを調べ、toString() を発火させている。
Concurrent Mode 向けの処理で、ID を強制的に更新させる狙いがあるとは思うけど、本当は違うのかもしれない、発火しないので…。

packages/react-dom/src/client/ReactDOMComponent.jsより

else if (
  typeof nextProp === 'object' &&
  nextProp !== null &&
  nextProp.$$typeof === REACT_OPAQUE_ID_TYPE
) {
  // If we encounter useOpaqueReference's opaque object, this means we are hydrating.
  // In this case, call the opaque object's toString function which generates a new client
  // ID so client and server IDs match and throws to rerender.
  nextProp.toString();
}

感想

何もわからんけど便利そう。

useOpaqueIdentifier が ID 参照に関する Hooks というのは、説明しないとわからないレベル。
最初は useUniqueId という名前だったけど、それぞれ異なる場所から Reference する意味が強く、 useOpaqueReference という名前に変わった。しかし今度は ref オブジェクトとして扱う印象を与えてしまうので、現在の useOpaqueIdentifier という名前になった。
それでもユースケースを的確に表せているとは言い難く、さらに変わってもおかしくなさそう。

アクセシビリティの闇でもあるこの問題に、ライブラリ側が解決に取り組むのはなかなか斬新で、React のアクセシビリティ対応への手厚さと、現時点でもだいぶ実用的な Hooks を開発したコアメンバーの腕力を感じる。
もちろん不安定なところもあり、これからは IDREFs(ARIA 属性に複数の ID を指定する)への対応などもあるようなので、もうしばらく追い続けようと思っている。

参考

React Hooks の useState がどういう原理で実現されてるのかさっぱりわからなかったので調べてみた - Subterranean Flower Blog