12k
All articles

Reactが不要な5つのこと

5つのネイティブブラウザAPIが、dialog、Popover、Custom Elements、container queries、View TransitionsなどのReactコンポーネントを置き換えます。

OpenReplay Team
OpenReplay Team
Reactが不要な5つのこと

ブラウザプラットフォームは、かつてReactコンポーネントやサードパーティライブラリを必要としていたいくつかのUIプリミティブに対して、ネイティブかつベースライン利用可能な代替手段を提供するようになりました。対象となるのは、モーダルダイアログ、ポップオーバーとツールチップ、フレームワーク非依存の再利用可能なウィジェット、コンテナ対応のレスポンシブレイアウト、そしてアニメーション付きビュートランジションです。本記事はReactを否定するものではありません。Reactは複雑な共有状態管理、大規模なフォームワークフロー、そしてNext.jsやRemixといったエコシステムにおいて依然として最適なツールです。本記事はメンテナーのための監査チェックリストです。Reactコードベースにすでに存在しているかもしれないコンポーネントのうち、ブラウザがネイティブに処理できるようになった5つのカテゴリを、JavaScriptバンドルサイズへの追加コストゼロで紹介します。

対象読者は、「ブラウザに何ができるか」というメンタルモデルが2020年頃から更新されていない現役のReact開発者です。React 19が現在の安定版リリースであり、そのエコシステムが解決してきたパターンのいくつかは、今やプラットフォーム自体の一部となっています。以下の各セクションでは、Reactでの慣習的なアプローチ、それを置き換えるネイティブAPI、そしてコードを削除する前に把握しておくべきアクセシビリティ上の注意点を説明します。

重要なポイント

  • showModal()を使用したHTML <dialog> 要素は、ネイティブのフォーカストラップ、Escapeキーによる閉じる動作、::backdrop 疑似要素を提供し、Reactのモーダルライブラリに依存する理由のほとんどを排除します。
  • Popover APIはブラウザのトップレイヤーに要素をレンダリングするため、手書きのReactツールチップやドロップダウンを悩ませるz-indexや overflow: hidden によるクリッピングのバグを根本から解消します。
  • Shadow DOMを組み合わせたCustom Elementsを使用すると、各スタックで再実装することなく、あらゆるフレームワークや通常のHTMLで動作するウィジェットを1つ作成して配布できます。
  • CSSコンテナクエリ(@container)を使用すると、コンポーネントがビューポートではなく親要素の幅に応じてレイアウトを変更できるため、レイアウト判断のためだけに使用していた ResizeObserver フックとReactの状態管理を置き換えられます。
  • View Transitions API(document.startViewTransition())はDOMの状態変化をネイティブにアニメーション化し、以前はFramer Motionや react-transition-group で処理していた多くのユースケースをカバーします。

モーダル:モーダルライブラリの代わりに <dialog> 要素を使用する

モーダルダイアログには、showModal() 経由で呼び出すネイティブのHTML <dialog> 要素を使用することで、フォーカストラップ、背景コンテンツの非活性化、Escapeキーによる閉じる動作、バックドロップのスタイリングが利用できます。これらはカスタムReactモーダルが手動で実装しなければならず、しばしば不完全な実装になりがちな動作です。<dialog> 要素はBaselineの一部です。社内ドキュメントを公開する前に、MDNで正確な利用可能日を確認してください。

Reactでの慣習的なアプローチ。 チームは通常、react-modal、Radix Dialog、またはポータルを使用したカスタム useModal フックを使用します。このフックパターンは通常、createPortaldocument.body.style.overflow を切り替える useEffect、そして手書きのフォーカストラップを組み合わせています。これらの実装の本番環境でのセッションリプレイを見ると、ユーザーがモーダルからタブキーで背景コンテンツに移動してしまうケースが頻繁に見られます。これはフォーカストラップのロジックが不完全であることの症状です。

ネイティブAPI。

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete project?</h2>
  <p>This action cannot be undone.</p>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm">Delete</button>
  </form>
</dialog>

<script>
  document.getElementById('confirm').showModal();
</script>

showModal() はダイアログをトップレイヤーに配置し、その内部にフォーカスをトラップし、ドキュメントの残りの部分を非活性化し、CSSでスタイリングできる ::backdrop 疑似要素をレンダリングします。<form method="dialog"> はダイアログを閉じ、クリックされたボタンの valuedialog.returnValue 経由で返します。イベントリスナーは不要です。

注意点。 アクセシビリティ上の落とし穴は、<dialog> がラベルを自動的にアナウンスしないことです。スクリーンリーダーがダイアログを識別できるよう、表示されている見出しを指す aria-labelledby(または aria-label)が必要です。ダイアログが非モーダル(showModal() ではなく show() で開かれる)の場合、フォーカスはトラップされないため、代わりにPopover APIを検討するとよいでしょう。他のコンポーネントと密接に連携した宣言的な状態ベースの開閉ロジックが必要な場合や、ダイアログのアンマウント前にアニメーションを実行する必要がある場合は、Reactまたはライブラリのほうがよりよい選択肢です。

ポップオーバー、ツールチップ、ドロップダウン:Popover APIを使用する

Popover APIはブラウザのトップレイヤーに要素をレンダリングします。これにより、スタッキングコンテキストや祖先要素の overflow: hidden に関係なく、ポップオーバーは常に他のコンテンツの上に表示されます。これにより、手書きのツールチップやドロップダウン実装で生じるz-index競合やクリッピングバグのカテゴリ全体を解消できます。

Reactでの慣習的なアプローチ。 Floating UIRadix Popover、React-Ariaのオーバーレイプリミティブはよくあるライブラリの依存関係です。これらは位置決め、外部クリックによる閉じる動作、ポータルレンダリングを処理します。シンプルなツールチップのためにインポートするコードとしては、かなりの量になります。

ネイティブAPI。

<button popovertarget="menu">Open menu</button>

<div id="menu" popover>
  <a href="/account">Account</a>
  <a href="/logout">Log out</a>
</div>

popover 属性だけで、JavaScriptなしに、popovertarget ボタンでトグルし、外部クリックとEscapeキーで閉じ、トップレイヤーにレンダリングされる要素が実現できます。デフォルト値の popover="auto" はライトディスミスを有効にし、popover="manual" は明示的な制御が必要な場合にそれを無効にします。Popover APIはBaseline Newly Availableです。現在の状況についてはMDNの互換性テーブルを確認してください。

注意点。 アクセシビリティ上の落とし穴は、<dialog>showModal() とは異なり、Popover APIがフォーカスを自動的に管理しないことです。ポップオーバーが機能的にメニューである場合、role="menu" の適用、ローリングタブインデックスの管理、ポップオーバーが開いたときのフォーカス移動は依然として手動で行う必要があります。トリガーに対する相対的な位置決めにはCSSアンカーポジショニングも必要ですが、こちらのBaselineステータスはより限定的です。クロスブラウザで使用する前にMDNで確認してください。サブメニュー、キーボードナビゲーションパターン、タイプアヘッドを持つ複雑なメニューには、RadixやReact-Ariaのようなライブラリが依然として実際の作業を大幅に削減してくれます。

再利用可能なウィジェット:Custom ElementsとShadow DOMを使用する

customElements.define() で登録されたCustom Elementは、React、Vue、Angular、Svelte、または通常のHTMLファイルなど、あらゆるHTMLコンテキストで再実装なしに動作します。Shadow DOMと組み合わせることで、CSS Modules、CSS-in-JS、またはビルドステップなしにスタイルのカプセル化が実現できます。Custom ElementsとShadow DOMはBaseline Widely Availableです。MDNで年を確認してください。

Web ComponentsはメインストリームのアプリケーションでReactの座を奪ってはいません。しかし、デザインシステムを管理したり、サードパーティの埋め込みを配布したりする場合に、同じウィジェットをフレームワークごとに5回実装する必要性を排除しました。

Reactでの慣習的なアプローチ。 再利用可能なボタン、バッジ、チャートをReactコンポーネントにラップしてnpmに公開し、異なるフレームワークを使用するチームのために再実装(または再ラップ)する。

ネイティブAPI。

class CopyButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>button { padding: 6px 12px; }</style>
      <button><slot>Copy</slot></button>
    `;
    this.shadowRoot.querySelector('button')
      .addEventListener('click', () => {
        navigator.clipboard.writeText(this.dataset.value ?? '');
      });
  }
}
customElements.define('copy-button', CopyButton);

JSXの内部を含む任意のHTMLで <copy-button data-value="hello">Copy</copy-button> として使用できます。React 19はオブジェクトpropsの受け渡しやカスタムイベントのリッスンを含め、Custom Elementsを直接サポートしています。

注意点。 アクセシビリティ上の落とし穴は、アクセシビリティツリーがデフォルトではShadow境界を貫通しないことです。ライトDOM内の aria-labelledbyaria-describedby の参照はShadowルート内のIDをターゲットにできず、その逆も同様です。ARIA in HTML仕様と進行中のreference targetプロポーザルはこの問題に対処していますが、現在の実用的なパターンでは、ホスト要素への明示的なARIA属性か、ElementInternalsを使用した attachInternals() が必要です。ウィジェットがアプリケーションの状態と密接に統合する必要がある場合、React Contextを共有する場合、またはSuspenseを使用する場合は、Reactのほうがよりよい選択肢です。

コンポーネントレベルのレスポンシブレイアウト:CSSコンテナクエリを使用する

CSSコンテナクエリ@container)を使用すると、コンポーネントがビューポートではなく自身の親要素の幅に基づいてレイアウトを適応させることができます。これにより、classNameを切り替えるためだけにコンテナのサイズをReactの状態で追跡する useResizeObserver フックパターンが不要になります。コンテナクエリはBaseline Widely Availableです。MDNで年を確認してください。

Reactでの慣習的なアプローチ。 useResizeObserver フック(多くの場合 @react-hook/resize-observer から、または手書き)をコンポーネントの状態に接続し、layout="compact" propまたはclassNameを切り替える。すべてのリサイズがReactの再レンダリングをトリガーしますが、その唯一の消費者はCSSです。

ネイティブAPI。

.card-container {
  container-type: inline-size;
}

.card {
  display: grid;
  grid-template-columns: 1fr;
}

@container (min-width: 400px) {
  .card {
    grid-template-columns: 120px 1fr;
  }
}

親要素に container-type: inline-size を宣言し、子要素に対して @container ルールを記述します。ブラウザがリサイズの監視をネイティブに処理します。JavaScriptなし、再レンダリングなし、ハイドレーションの不一致なし。

:has() セレクターは状態を考慮したスタイリングにおいてこれを補完します。form:has(input:invalid) button[type="submit"] { opacity: 0.5 } のようなルールは、以前は useState とコントロールされた入力パターンが必要だったものを表現できます。:has() はBaseline Widely Availableです。MDNで確認してください。

注意点。 アクセシビリティ上の考慮事項は微妙ですが重要です。コンテナクエリはDOMの順序を変えずにレイアウトを大幅に変更できます。これはスクリーンリーダーにとっては良いことですが、各ブレークポイントで読み上げ順序が視覚的な順序と一致していることを確認する必要があります。また、コンテナクエリはコンテインメントの動作を導入するため、子孫要素のレイアウトや配置に影響を与える可能性があります。ビューポート相対の位置決めや他のレイアウトの前提に依存するコンポーネントはテストしてください。レイアウトの判断がスタイリング以上のことを駆動する場合、例えば単にスタイルを変更するのではなく異なるコンポーネントツリーをレンダリングする必要がある場合は、Reactの状態管理が依然として必要です。

アニメーション付きトランジション:View Transitions APIを使用する

View Transitions APIは、デフォルトでクロスフェードアニメーションでDOMの更新をラップし、::view-transition-* 疑似要素を通じてトランジションをCSSで完全に制御できます。同一ドキュメント内のトランジションについては、以前はアニメーションライブラリが必要だったルートおよび状態トランジションアニメーションの大部分をカバーします。

Reactでの慣習的なアプローチ。 ルートコンポーネントを Framer Motionreact-transition-group、または AnimatePresence でラップする。これらは機能しますが、アニメーションをReactのレンダリングモデルで表現できる必要があり、1つのツリーのアンマウントと別のツリーのマウントにまたがるトランジションには不向きです。

ネイティブAPI。

function navigate(url) {
  if (!document.startViewTransition) {
    updateDOM(url);
    return;
  }
  document.startViewTransition(() => updateDOM(url));
}

document.startViewTransition() はDOMの更新を実行するコールバックを受け取ります。ブラウザは変更前の状態をキャプチャし、コールバックを実行し、変更後の状態をキャプチャして、それらの間でクロスフェードします。トランジション全体で特定の要素をアニメーション化するには(例えば、サムネイルが詳細ビューに展開する場合)、CSSで一致する要素に同じ view-transition-name を付与します。同一ドキュメントのView TransitionsはBaseline Newly Availableです。クロスドキュメントのView Transitions(MPAナビゲーション用)はサポートがより限定的です。クロスドキュメントモードを使用する前に、MDNの互換性テーブルWebKitブログで現在のSafariの状況を確認してください。

注意点。 アクセシビリティ上の落とし穴はモーションです。メディアクエリでトランジションをラップするか、オプトアウトしたユーザーに対してはその呼び出しを完全にスキップすることで、prefers-reduced-motion を尊重してください。デフォルトのクロスフェードは短いですが、それでもアニメーションです。スプリング物理演算、ジェスチャー駆動のトランジション、または途中で中断・反転するアニメーションが必要な場合は、Reactライブラリのほうがよりよい選択肢です。View Transitionsはアトミックであり、そのような用途向けに設計されていません。

Reactが依然として優位な領域

上記の5つの置き換えは特定のコンポーネントカテゴリを対象としています。以下のすべてにおいて、Reactは依然として正しいツールであり、プラットフォーム機能で置き換えることはコスト削減よりも多くのコストを生じさせます。

  • 離れたコンポーネント間の複雑な共有状態。 UIの複数の無関係な部分が、派生セレクターを持つ同じ変化する状態をサブスクライブする場合、ZustandJotai、またはRedux Toolkitのようなライブラリはプラットフォームが行わない作業を担います。Web ComponentsのCustom Eventsはデータを運べますが、派生状態をモデル化しません。
  • クロスフィールドバリデーションと動的レンダリングを伴う大規模なフォームワークフロー。 ネイティブの <form>Constraint Validation API、および FormData は単一フォームの送信をきれいに処理します。しかし、マルチステップウィザード、フォーム内の他の値に依存する条件付きフィールド、クライアントバリデーションとマージされたサーバー駆動のバリデーション、フィールド配列は依然としてReact Hook FormやTanStack Formの恩恵を受けます。
  • サーバー駆動のレンダリングとデータフェッチ。 React Server Components、非同期データ用のuse() フック、およびNext.jsRemixのストリーミングSSRモデルは、プラットフォームが直接対処していないハイドレーション、コード分割、データフェッチの調整問題を解決します。
  • ルーティングとデータレイヤーのエコシステムの成熟度。 TanStack RouterTanStack Query、および確立されたReact Routerエコシステムは、ネイティブAPIに対して再現するには相当な作業が必要なキャッシュ無効化、楽観的更新、ルートローダーパターンを提供します。
  • チームの慣習と既存の投資。 Reactを中心に構築されたコードベース、採用パイプライン、デザインシステム、CIはそれ自体が資産です。ここでの監査の姿勢は、プラットフォームが十分に対応できるようになった特定のコンポーネントを削除することであり、スタック全体を移行することではありません。

実践的なアクション:最大のReactコンポーネントディレクトリを開き、ModalPopoverTooltipDropdown、および useResizeObserver のインポートを検索してください。それぞれが上記のネイティブ置き換えの候補です。サポートするブラウザ範囲に対してMDNでAPIのBaselineステータスを確認し、フィーチャーフラグの後ろで置き換えを実施し、バンドルサイズの変化を測定してください。ブラウザは追いついてきました。残る作業は、もはや必要のない依存関係を監査することです。

よくある質問

ネイティブのdialog要素をReactの状態モデルと組み合わせて使用できますか?

はい。dialog要素にrefをアタッチし、Reactの状態によって駆動されるエフェクトからref.current.showModal()またはref.current.close()を呼び出します。ダイアログはReactツリー内に留まり、JSXの子要素を通常通り受け入れますが、レンダリング出力のuseStateによるopen propをバイパスします。主な摩擦点は、Reactがダイアログの内部cancelイベントに対してエフェクトを再実行しないことです。そのため、状態を同期させるためにuseEffectを通じてネイティブのcloseリスナーをアタッチしてください。

Custom ElementsはReactとの間でどのように複雑なデータをやり取りしますか?

React 19は文字列以外のpropsを属性にシリアライズするのではなく、Custom Elementのプロパティに直接渡すため、JSONエンコーディングなしにオブジェクトや配列が機能します。Custom ElementsはCustomEventを通じてデータを返し、React 19はonプレフィックスの標準ハンドラーprops(例:onMyEvent)を使用してそれをリッスンします。React 18以前では、合成イベントがカスタムイベント名を処理しないため、refを通じて命令的にイベントリスナーをアタッチする必要があります。

コンテナクエリと:has()セレクターはレンダリングパフォーマンスに悪影響を与えますか?

どちらも測定可能なコストがありますが、一般的にそれらが置き換えるJavaScriptの代替手段よりも安価です。コンテナクエリはブラウザがコンテインメントコンテキストを維持し、サイズ変更時にマッチングルールを再評価する必要がありますが、それでもResizeObserverコールバックがReactの再レンダリングをトリガーするよりも高速です。:has()セレクターは、大きなDOMツリー全体にわたる広範なサブジェクトセレクターと組み合わせると高コストになる可能性があります。bodyやルートレベルの要素に適用するのではなく、特定の親要素にスコープを絞ってください。

View Transitions APIはReact Routerのようなクライアントサイドルーターと連携しますか?

はい、同一ドキュメントのトランジションについては機能します。ルートの変更中にReactが実行するDOM更新がトランジション内で実行されるよう、ルーターのナビゲーションコールバックをdocument.startViewTransition()でラップします。React Router v6とTanStack Routerはどちらもナビゲーションインターセプションを通じてこのパターンをサポートしています。フルページロードをアニメーション化するクロスドキュメントビュートランジションは、@view-transition CSSルールを通じた追加のオプトインが必要で、ブラウザサポートがより限定的です。使用する前にMDNで確認してください。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.