React 19.3でFragment Refを使う方法
React 19.3のFragment refで、ラッパーなしに兄弟要素のイベント、フォーカス、監視、測定を扱う方法を解説。各メソッドの制限とDOM要素が必要な場面も紹介します。
React 19.3では、<Fragment>にrefを渡すとFragmentInstanceを取得できます。これはFragmentの子要素全体をひとまとまりとして扱うDOMメソッドを持つオブジェクトで、ページに余分な要素を追加しません。
兄弟要素のグループにrefを付けるためだけに<div>でラップし、その結果カードがコンテナの直接の子でなくなってCSS gridやflexの行レイアウトが崩れた、という経験があるかもしれません。Fragment refを使えば、DOM構造を変えずに、兄弟要素のグループへイベントリスナー、オブザーバー、フォーカス制御を適用できます。本ガイドではリリース内容の全体的な振り返りは省略します(以前の機能についてはReact 19の新機能を参照してください)。ここでは、APIの仕組み、各メソッドがどの子要素に作用するか、そしてラッパー要素を使い続けるべきケースについて解説します。
重要なポイント
- Fragment refはReact 19.3で安定版(stable)になりました。
<></>の省略記法ではrefを受け取れないため、Fragmentをimportして<Fragment ref={ref}>と記述する必要があります。 - refが保持するのは
FragmentInstanceであり、DOMノードでも配列でもありません。イベント、フォーカス、オブザーバー、計測/スクロールの4グループに分類される12個のメソッドを公開しています。 - イベント、オブザーバー、計測系のメソッドは、Fragmentの第1階層のDOM子要素にのみ作用します。
focus()とfocusLast()は、ネストされた要素を深さ優先で探索します。 FragmentInstanceのscrollIntoView()はalignToTopのboolean値のみを受け付け、optionsオブジェクトを渡すとエラーをスローします。
ラッパーdiv問題とは
React 19.3以前は、兄弟要素のグループを操作するには、実際のDOM要素でラップしてその要素にrefを付けるしかありませんでした。ラッパーはレイアウトツリーを変化させ、CSS gridやflexboxといったレイアウトシステムは直接の子要素しか認識しません。
// Before: the div is now the only grid item
<div className="grid">
<div ref={groupRef}><Card /><Card /><Card /></div>
</div>
// After: each card is still a grid item
<div className="grid">
<Fragment ref={groupRef}><Card /><Card /><Card /></Fragment>
</div>
ラッパーdiv版では、カードはgridではなくラッパーの子要素になります。一方Fragment版では、refがまったくない場合とまったく同じDOMが維持されます。
React Fragment refの仕組み
React Fragment refは他のrefと同様に機能しますが、Reactが割り当てる値がFragmentInstanceである点が異なります。Fragment Refは、2026年9月9日に公開されたReact 19.3リリースで安定版の機能となりました。React 19.3以降(19.3.1パッチを含むreactおよびreact-domの19.3.0以上)が必要なため、両パッケージとも同じ19.3.xバージョンをインストールしてください。古いメジャーバージョンから移行する場合は、まずReact 19へのアップグレード時によくある間違いを確認してください。
<Fragment>のリファレンスには、12個のメソッドが記載されています。
| メソッドグループ | メソッド | 作用範囲 | 備考 |
|---|---|---|---|
| イベント | addEventListener, removeEventListener, dispatchEvent | 第1階層の子要素 | dispatchEventはFragment上で発火し、DOM上の親要素へバブリングできる |
| フォーカス | focus, focusLast, blur | ネストされた要素(深さ優先) | focus()はFocusOptionsを受け付ける |
| オブザーバー | observeUsing, unobserveUsing | 第1階層の子要素 | IntersectionObserverまたはResizeObserver。テキストノードは非対応 |
| 計測/スクロール | getClientRects, getRootNode, compareDocumentPosition, scrollIntoView | 第1階層の子要素 | getRootNodeは親要素を基点に動作。scrollIntoViewはboolean値のみ受け付ける |
このAPIで戸惑うポイントの多くは、この「第1階層ルール」に起因します。以下の短いネスト構造の例で、各メソッドグループがどこまで作用するかを正確に確認できます。
<Fragment ref={groupRef}>
<div className="row"> {/* first-level: gets listeners, observed, measured */}
<button>Save</button> {/* nested: not observed; focus() can reach it */}
</div>
<Card /> {/* component: the element Card renders at its top level is first-level */}
Some text {/* text node: observeUsing does not work on it */}
</Fragment>
コンポーネントは階層としてカウントされません。重要なのは各コンポーネントがトップレベルでレンダリングするDOM要素であり、そのため<Card />はReactコンポーネントであっても第1階層として扱われます。
すべての子要素に1つのイベントリスナーを追加するには
FragmentInstanceでaddEventListenerを呼び出すと、第1階層のすべてのDOM子要素に対して一度にハンドラーが登録されます。effect内で登録し、クリーンアップで削除してください。その際、両方で同じ関数参照を渡す必要があります。
import { Fragment, useEffect, useRef } from 'react';
function ClickGroup({ onClick, children }) {
const groupRef = useRef(null);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
group.addEventListener('click', onClick);
return () => group.removeEventListener('click', onClick);
}, [onClick]);
return <Fragment ref={groupRef}>{children}</Fragment>;
}
リファレンスによると、子要素がFragmentに追加・削除されるのに合わせて、Reactがリスナーを自動的に追加・削除します。onClickには安定した参照を渡してください(たとえばuseCallbackでラップするなど)。レンダリングのたびに新しい関数が生成されると、effectが毎回購読解除と再購読を繰り返すことになります。
ネストされた要素でも、イベントがバブリングすればリスナーはトリガーされます。第1階層の<div>内にある<button>をクリックすると、イベントはハンドラーが登録されたその<div>までバブリングします。ただし、focusやmouseenterのようにバブリングしないイベントは、より深い階層の要素からリスナーには届きません。
observeUsingでグループを監視するには
observeUsing()は、作成済みのIntersectionObserverまたはResizeObserverを、第1階層のすべてのDOM子要素に接続します。unobserveUsing()は、同じオブザーバーインスタンスをそれらの子要素から切り離します。
import { Fragment, useEffect, useRef } from 'react';
function VisibilityGroup({ onVisibilityChange, children }) {
const groupRef = useRef(null);
useEffect(() => {
const group = groupRef.current;
if (!group) return;
const visible = new Set();
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target);
else visible.delete(entry.target);
}
onVisibilityChange(visible.size > 0);
});
group.observeUsing(observer);
return () => {
group.unobserveUsing(observer);
observer.disconnect();
};
}, [onVisibilityChange]);
return <Fragment ref={groupRef}>{children}</Fragment>;
}
Setで現在交差している子要素を追跡しているため、いずれかのカードが画面上にある間はグループ全体が表示中とみなされます。子要素は、refを一切受け付けないコンポーネントでも構いません。Fragmentのリファレンスに記載されているとおり、テキストノードは監視対象外であり、Fragmentがテキストのみを含む場合、Reactは開発時に警告をログ出力します。
focus()でフォーカスを移動する
Fragment refでfocus()を呼び出すと、グループ内の最初のフォーカス可能な要素にフォーカスが移動します。フォーカス系メソッドはネストされた子要素を深さ優先で探索するため、refを公開していない子コンポーネントがレンダリングする<label>や<fieldset>の内部にinputがある場合でも機能します。
import { Fragment, useEffect, useRef, useState } from 'react';
function EditAddress() {
const [editing, setEditing] = useState(false);
const fieldsRef = useRef(null);
useEffect(() => {
if (editing) fieldsRef.current?.focus();
}, [editing]);
return (
<section>
<button onClick={() => setEditing(true)}>Edit address</button>
{editing && (
<Fragment ref={fieldsRef}>
<AddressFields />
</Fragment>
)}
</section>
);
}
フォームやモーダルのセッションリプレイを見ると、状態変更後にフォーカスがどこにも当たらず、次のキー入力が<body>に送られてしまう様子が確認できることがあります。Fragment refでfocus()を呼び出せば、ラッパーなしでこの問題を解決できます。最後のフォーカス可能な要素を対象にするにはfocusLast()を使用し、必要に応じて{ preventScroll: true }などのFocusOptionsを渡してください。refコールバックも使用できますが、レンダリングのたびに再実行されないよう、安定した参照を保ってください。
ラッパー要素が依然として必要になるのはどんなときか
DOMノードそのものが必要な場合は、ラッパー要素を使用してください。Fragment refは子要素のグループに作用するものであり、ボックスを生成するわけではありません。次のような場合はラッパーを残しましょう。
- グループにスタイルを適用したい場合。 背景、ボーダー、パディング、grid areaには、いずれも実際の要素が必要です。
- 単一のバウンディングボックスが必要な場合。
getClientRects()は子要素の矩形をフラットな配列で返すため、1つに統合された矩形は得られません。 - 対象が第1階層の子要素より深くネストされている場合。 オブザーバーやバブリングしないイベントは、それらの要素には届きません。
また、scrollIntoView()がalignToTopしか受け付けない点にも注意してください。スムーズスクロールやblockによる位置揃えが必要な場合は、実際のノードで要素のメソッドを使用してください。
まとめ
Fragment refを使えば、gridやflexのレイアウトを崩すことなく、兄弟要素のグループにリスナー、オブザーバー、フォーカスを適用できます。使用前には第1階層ルールを確認しましょう。イベント、オブザーバー、計測は直接のDOM子要素までしか作用しませんが、フォーカスはより深い階層まで探索します。試してみるには、React 19.3にアップグレードし、ref付与のためだけに存在するラッパーdivを1つ<Fragment ref>に置き換えてみてください。同じリリースではTrusted Typesのサポートも追加されており、詳細はReact 19.3のリリース記事で説明されています。
よくある質問
TypeScriptでFragment refに型を付けるには?
reactからexportされているFragmentInstance型を指定してuseRefを使用し、初期値にnullを渡します。@types/react 19.3では、FragmentInstanceインターフェースはデフォルトで空になっています。@types/react-domがdeclare module 'react'を通じてこれを拡張し、focus、observeUsing、addEventListenerなどのメソッドを追加します。これらのメソッドが見つからない場合は、@types/react-domをインストールし、react-domモジュールがプロジェクトの型グラフに含まれていることを確認してください。
Fragment refのscrollIntoViewはどの子要素までスクロールしますか?
alignToTopがtrueまたは省略された場合、scrollIntoView()はFragmentの最初の子要素をスクロール可能な祖先要素の上端に揃えます。alignToTopがfalseの場合は、最後の子要素を下端に揃えます。空のFragmentの場合は、最も近い兄弟要素または親要素にフォールバックします。behaviorに'smooth'を指定したものなど、optionsオブジェクトを渡すと、スクロールせずにエラーをスローします。
Fragment refのイベントリスナーは、非表示のActivity境界内でも機能しますか?
いいえ。FragmentInstanceのaddEventListenerで追加したリスナーは、非表示のActivity境界内にある子要素には適用されません。境界が非表示から表示に切り替わると、Reactがそれらのリスナーを自動的に適用します。コンテンツが表示された後に、自分のコードでリスナーを再登録する必要はありません。
フォーカスがグループ外にある場合、Fragment refのblur()はどのように動作しますか?
何も起こりません。FragmentInstanceのblur()は、document.activeElementがFragment内にある場合にのみ、その要素からフォーカスを外します。アクティブな要素がページ上の別の場所にある場合、呼び出しは何も行いません。そのため、フォーカスの位置を事前に確認することなく、closeやcancelのハンドラー内でblur()を呼び出せます。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k