Fragmentとは、複数の要素を「余分なタグを出力せずに」1つにまとめるためのReactの仕組みです。 コンポーネントが返すJSXは1つの親要素にまとまっている必要がありますが、そのために div で囲むと、HTMLに不要な階層が増えて表やレイアウトが崩れることがあります。Fragmentで囲めば、出力されるHTMLには何も足されません。

書き方は、空タグ、Fragment、React.Fragment の3つがあり、ふだんは空タグ、map の中でkeyが必要なときとrefを使うときは Fragment と使い分けます。この記事では、空タグにkeyを書いたときのエラー文、ルート要素が2つあるときの JSX expressions must have one parent element を再現し、table・dl・flex・gridで div を挟んだ場合とFragmentの場合を、出力されたHTMLとブラウザでの位置の計測で比べます。React 19.3.0で動くFragmentのref、条件分岐や配列で返す書き方との使い分けまで、動かした出力つきでまとめました。

確認環境:Ubuntu 24.04、Node.js 22.22.2、React 19.3.0 / react-dom 19.3.0、TypeScript 7.0.2、@types/react 19.3.0、Vitest 5.0.3(Vite 8.3.2)、jsdom 30.1.1、@testing-library/react 16.3.3、Playwright 1.56.0のChromium 141(画面サイズ800×600)。

Fragmentの早見表

3つの書き方の違いです。

書き方 importの要否 key ref 出力されるHTML
空タグ(<> と </> で囲む) 不要 書けない 書けない 何も足されない
Fragment import { Fragment } from "react" 書ける 書ける 何も足されない
React.Fragment import React from "react" 書ける 書ける 何も足されない

Fragment と React.Fragment は同じものです。importの書き方が違うだけなので、どちらか一方に統一すれば十分です。

場面 選ぶ書き方
コンポーネントから複数の要素を返す 空タグ
map の中で複数の要素を1組として返す Fragment にkeyを付ける
条件によって複数の要素を出し分ける 空タグ
文字とタグをまとめて変数やpropsに入れる 空タグ
まとめた要素にイベントやフォーカス操作をまとめて行う Fragment にrefを付ける
classNameやstyleを付けたい、枠として表示したい div などの要素(Fragmentには付けられない)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Fragmentの使い方 空タグとkey付きの使い分け」

コピペで動かすための準備

空のフォルダで次を実行すると、この記事と同じ環境になります。

npm init -y
npm i react react-dom typescript vitest jsdom \
  @testing-library/react @testing-library/dom \
  @types/react @types/react-dom playwright-core@1.56.0
// vitest.config.mts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "jsdom", globals: true } });
{
  "compilerOptions": {
    "target": "ES2023", "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "ESNext", "moduleResolution": "Bundler",
    "jsx": "react-jsx", "strict": true, "skipLibCheck": true, "noEmit": true,
    "types": ["vitest/globals"]
  },
  "include": ["a3/ok"]
}

テストファイルの先頭は共通です。各例では log という配列に文字列をため、最後に out() でまとめて表示します。capture() は、Reactが console.error に出すメッセージの先頭4行までを記録する関数です。実行は npx vitest run a3 --silent=false です。

// a3/ok/fragment.test.tsx の先頭
import React, { Fragment, useRef, useState, useEffect } from "react";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";

afterEach(cleanup);
const log: string[] = [];
const out = (title: string) => { console.log(`--- ${title}\n` + log.join("\n")); log.length = 0; };
const capture = (fn: () => void) => {
  const spy = vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => {
    let i = 1;
    const msg = String(a[0]).replace(/%s/g, () => String(a[i++]));
    log.push("console.error: " + msg.split("\n").filter((l) => l.trim()).slice(0, 4).join("\n  "));
  });
  try { fn(); } finally { spy.mockRestore(); }
};

3つの書き方が同じHTMLになることを確かめる

renderToStaticMarkup は、JSXをHTMLの文字列に変換する関数です。3つの書き方と、div で囲んだ場合を並べます。

test("1. 3つの書き方は同じHTMLになる", () => {
  const a = <><h2>題</h2><p>文</p></>;
  const b = <Fragment><h2>題</h2><p>文</p></Fragment>;
  const c = <React.Fragment><h2>題</h2><p>文</p></React.Fragment>;
  const d = <div><h2>題</h2><p>文</p></div>;
  log.push("<>              : " + renderToStaticMarkup(a));
  log.push("<Fragment>      : " + renderToStaticMarkup(b));
  log.push("<React.Fragment>: " + renderToStaticMarkup(c));
  log.push("<div>           : " + renderToStaticMarkup(d));
  log.push(`Fragment === React.Fragment: ${Fragment === React.Fragment}`);
  log.push(`(<></>).type === Fragment: ${(<></>).type === Fragment}`);
  log.push(`typeof Fragment: ${typeof Fragment} / ${String(Fragment)}`);
  out("same html");
});

実行結果:

--- same html
<>              : <h2>題</h2><p>文</p>
<Fragment>      : <h2>題</h2><p>文</p>
<React.Fragment>: <h2>題</h2><p>文</p>
<div>           : <div><h2>題</h2><p>文</p></div>
Fragment === React.Fragment: true
(<></>).type === Fragment: true
typeof Fragment: symbol / Symbol(react.fragment)

3つとも出力は同じで、囲みのタグは何も足されていません。 Fragment === React.Fragment も、空タグで作った要素の型が Fragment であることも true です。3つは書き方が違うだけで、同じものを指しています。

空タグにimportがいらない理由

空タグがどんなJavaScriptに変換されるかを、TypeScriptで出力して確かめます。

// a3/ng/classic.tsx
export function A() {
  return <><p>a</p></>;
}

jsx の設定が react-jsx のとき:

import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
export function A() {
    return _jsx(_Fragment, { children: _jsx("p", { children: "a" }) });
}

jsx の設定が react のとき:

export function A() {
    return React.createElement(React.Fragment, null,
        React.createElement("p", null, "a"));
}

react-jsx では、変換のときに Fragment のimportが自動で足されます。そのため、空タグを使うだけなら自分でimportを書く必要はありません。 react の設定では React.Fragment に変換されるので、ファイルに import React from "react" が必要です。importが無いまま型チェックすると、次のエラーになりました(Fragmentに関係する2行を抜き出しています)。

a3/ng/classic.tsx(2,10): error TS2874: This JSX tag requires 'React' to be in scope, but it could not be found.
a3/ng/classic.tsx(2,10): error TS2879: Using JSX fragments requires fragment factory 'React' to be in scope, but it could not be found.

ルート要素が2つあるときのエラーを再現する

Fragmentが必要になる一番多い場面です。親でまとめずに2つの要素を返します。

// a3/ng/two-roots.tsx
export function Profile() {
  return (
    <h2>名前</h2>
    <p>自己紹介</p>
  );
}

TypeScript(tsc --noEmit)の出力:

a3/ng/two-roots.tsx(3,5): error TS2657: JSX expressions must have one parent element.

同じ形のコードをVitest(Vite 8.3.2)で読み込んだときの出力(メッセージの部分):

[PARSE_ERROR] Adjacent JSX elements must be wrapped in an enclosing tag.
Help: Did you want a JSX fragment `<>...</>`?

ツールによって文面は違いますが、どちらも「JSXは1つの親にまとめてください」という意味です。Viteのメッセージは、空タグで囲む方法まで案内してくれています。

直した後:

export function Profile() {
  return (
    <>
      <h2>名前</h2>
      <p>自己紹介</p>
    </>
  );
}

1つにまとめる必要があるのは、JSXが関数呼び出しに変換されるためです。前の節で見たとおり、JSXの1つのかたまりは _jsx(...) という1つの式になります。関数が return で返せる値は1つなので、2つの式を並べては返せません。Fragmentは「複数の子を持つ1つの値」を作る役目をしています。

空タグにkeyを書いたときのエラーと直し方

map の中で複数の要素を1組として返すとき、空タグにkeyを書きたくなります。

// a3/ng/empty-key.tsx
const terms = [{ id: "a", term: "state", desc: "値" }];
export function Glossary() {
  return (
    <dl>
      {terms.map((t) => (
        <key={t.id}>
          <dt>{t.term}</dt>
          <dd>{t.desc}</dd>
        </>
      ))}
    </dl>
  );
}

TypeScriptの出力(先頭の4行):

a3/ng/empty-key.tsx(6,15): error TS1131: Property or signature expected.
a3/ng/empty-key.tsx(6,16): error TS1005: ',' expected.
a3/ng/empty-key.tsx(6,19): error TS1005: ',' expected.
a3/ng/empty-key.tsx(6,20): error TS1382: Unexpected token. Did you mean `{'>'}` or `&gt;`?

同じ書き方をVitest(Vite 8.3.2)で読み込んだときの出力(メッセージの部分):

[PARSE_ERROR] Expected `(` but found `<`

空タグは「名前のないタグ」なので、属性を書く場所がありません。 エラー文にkeyという言葉は出てこず、構文エラーとして報告されるため、原因に気づきにくいところです。6行目あたりで構文エラーが並んだら、空タグに属性を書いていないかを確認します。

では、keyを書かずに空タグのまま map で返すとどうなるでしょうか。

test("4. key のない Fragment を map で返す", () => {
  const terms = [{ id: "a", term: "state" }, { id: "b", term: "props" }];
  capture(() => { render(<dl>{terms.map((t) => <><dt>{t.term}</dt><dd>-</dd></>)}</dl>); });
  cleanup();
  capture(() => { render(<dl>{terms.map((t) => <Fragment key={t.id}><dt>{t.term}</dt><dd>-</dd></Fragment>)}</dl>); });
  log.push("key を付けた後の console.error の数: " + (log.length - 1));
  out("map key");
});

実行結果:

--- map key
console.error: Each child in a list should have a unique "key" prop.
  Check the top-level render call using <dl>. See https://react.dev/link/warning-keys for more information.
key を付けた後の console.error の数: 0

空タグのままだと、画面は表示されますがkeyの警告が出ます。中の dt や dd にkeyを付けても、map が返しているのは外側のFragmentなので警告は消えません。map が直接返す要素、つまりFragmentにkeyを付けると警告が0件になります。

直す前 直した後
空タグにkeyを書く(構文エラー) Fragment に書き換えて key={t.id} を付ける
空タグのまま map で返す(keyの警告) Fragment に書き換えて key={t.id} を付ける
keyのためだけに div で囲む Fragment にkeyを付ける(HTMLは増えない)

React公式リファレンス(Fragment)にも、keyを渡すときは空タグの書き方は使えず、react から Fragment をimportして書く、と説明されています。

Fragmentに渡せるのはkeyとrefとchildrenだけ

classNameやonClickを付けるとどうなるかを確かめます。

test("5. Fragment に渡せる props", () => {
  // @ts-expect-error className は渡せない
  capture(() => { render(<Fragment className="box"><p>a</p></Fragment>); });
  cleanup();
  // @ts-expect-error onClick は渡せない
  capture(() => { render(<Fragment onClick={() => {}}><p>a</p></Fragment>); });
  out("props");
});

実行結果:

--- props
console.error: Invalid prop `className` supplied to `React.Fragment`. React.Fragment can only have `key`, `ref`, and `children` props.
console.error: Invalid prop `onClick` supplied to `React.Fragment`. React.Fragment can only have `key`, `ref`, and `children` props.

TypeScriptでも型エラーになります。同じファイルで ref と key を付けた行はエラーになりませんでした。

a3/ng/props.tsx(6,17): error TS2322: Type '{ className: string; children: Element; }' is not assignable to type 'IntrinsicAttributes & FragmentProps'.
  Property 'className' does not exist on type 'IntrinsicAttributes & FragmentProps'.

Fragmentは画面に何も出力しないので、見た目やクリックに関する属性を受け取る相手がいません。classNameやstyle、イベントを付けたいときは、div や section などの要素を使う場面です。

tableのtr内でdivを挟むとどうなるか

表の行の中身(セルの並び)を別コンポーネントに切り出す例です。div で囲んだ場合とFragmentの場合を比べます。

test("2. table", () => {
  type Item = { id: number; name: string; price: number };
  const items: Item[] = [{ id: 1, name: "ノート", price: 200 }, { id: 2, name: "ペン", price: 120 }];
  const CellsDiv = ({ item }: { item: Item }) => <div><td>{item.name}</td><td>{item.price}</td></div>;
  const CellsFrag = ({ item }: { item: Item }) => <><td>{item.name}</td><td>{item.price}</td></>;
  const Table = ({ Cells }: { Cells: (p: { item: Item }) => ReactNode }) => (
    <table><tbody>{items.map((it) => <tr key={it.id}><Cells item={it} /></tr>)}</tbody></table>
  );
  const htmlDiv = renderToStaticMarkup(<Table Cells={CellsDiv} />);
  const htmlFrag = renderToStaticMarkup(<Table Cells={CellsFrag} />);
  log.push("div     : " + htmlDiv);
  log.push("Fragment: " + htmlFrag);
  for (const [name, html] of [["div", htmlDiv], ["Fragment", htmlFrag]]) {
    const box = document.createElement("div");
    box.innerHTML = html;
    log.push(`${name} をHTMLとして読み込んだ後: ${box.innerHTML}`);
    log.push(`  tr の数=${box.querySelectorAll("tr").length} td の数=${box.querySelectorAll("td").length}`);
  }
  capture(() => { render(<Table Cells={CellsDiv} />); });
  out("table");
});

実行結果:

--- table
div     : <table><tbody><tr><div><td>ノート</td><td>200</td></div></tr><tr><div><td>ペン</td><td>120</td></div></tr></tbody></table>
Fragment: <table><tbody><tr><td>ノート</td><td>200</td></tr><tr><td>ペン</td><td>120</td></tr></tbody></table>
div をHTMLとして読み込んだ後: <div></div><div></div><table><tbody><tr><td>ノート</td><td>200</td></tr><tr><td>ペン</td><td>120</td></tr></tbody></table>
  tr の数=2 td の数=4
Fragment をHTMLとして読み込んだ後: <table><tbody><tr><td>ノート</td><td>200</td></tr><tr><td>ペン</td><td>120</td></tr></tbody></table>
  tr の数=2 td の数=4
console.error: In HTML, <td> cannot be a child of <div>.
  This will cause a hydration error.
    <Table Cells={function CellsDiv}>
      <table>
console.error: In HTML, <div> cannot be a child of <tr>.
  This will cause a hydration error.
    <Table Cells={function CellsDiv}>
      <table>
console.error: <tr> cannot contain a nested <div>.
  See this log for the ancestor stack trace.

読み取れることは3つです。

  • div で囲むと、出力されるHTMLは tr の直下に div が入った形になります。下のReactの警告にもあるとおり、HTMLでは tr の直下に div を置けません。
  • そのHTMLを読み込むと、div は空のままtableの外(前)に押し出されました。 サーバー側で作ったHTMLと、Reactが想定している構造が食い違うことになります。Chromium 141で同じHTMLを読み込んだ場合も、div がtableの前に移動する同じ結果でした。
  • Reactも開発時に In HTML, <div> cannot be a child of <tr>. と、サーバーで描画したHTMLと合わなくなる(hydration error)ことを知らせてくれます。

Fragmentなら、tr の直下に td が並ぶ正しい構造のままです。table・tr・tdのように「親子の組み合わせが決まっている要素」では、Fragmentで囲むのが確実です。同じ警告は、p の中に div を置いたときにも出ることを確認しました(In HTML, <div> cannot be a descendant of <p>.)。

dlのdtとddをmapで並べる

用語と説明を並べる dl では、1件につき dt と dd の2つを返すため、keyを付けたFragmentがちょうど合います。

test("3. dl", () => {
  const terms = [{ id: "a", term: "state", desc: "コンポーネントが持つ値" }, { id: "b", term: "props", desc: "親から受け取る値" }];
  const withDiv = <dl>{terms.map((t) => <div key={t.id}><dt>{t.term}</dt><dd>{t.desc}</dd></div>)}</dl>;
  const withFrag = <dl>{terms.map((t) => <Fragment key={t.id}><dt>{t.term}</dt><dd>{t.desc}</dd></Fragment>)}</dl>;
  log.push("div     : " + renderToStaticMarkup(withDiv));
  log.push("Fragment: " + renderToStaticMarkup(withFrag));
  for (const [name, node] of [["div", withDiv], ["Fragment", withFrag]] as const) {
    const box = document.createElement("div");
    box.innerHTML = renderToStaticMarkup(node);
    log.push(`${name}: dl の直接の子=${[...box.querySelector("dl")!.children].map((c) => c.tagName.toLowerCase()).join(",")} / "dl > dt" に一致する数=${box.querySelectorAll("dl > dt").length}`);
  }
  capture(() => { render(withDiv); });
  log.push("dl > div での console.error の数: " + (log.filter((l) => l.startsWith("console.error")).length));
  const pDiv = <p><div>a</div></p>;
  capture(() => { render(pDiv); });
  out("dl");
});

実行結果:

--- dl
div     : <dl><div><dt>state</dt><dd>コンポーネントが持つ値</dd></div><div><dt>props</dt><dd>親から受け取る値</dd></div></dl>
Fragment: <dl><dt>state</dt><dd>コンポーネントが持つ値</dd><dt>props</dt><dd>親から受け取る値</dd></dl>
div: dl の直接の子=div,div / "dl > dt" に一致する数=0
Fragment: dl の直接の子=dt,dd,dt,dd / "dl > dt" に一致する数=2
dl > div での console.error の数: 0
console.error: In HTML, <div> cannot be a descendant of <p>.
  This will cause a hydration error.
  > <p>
  >   <div>

dl の中に div を置いても、Reactの警告は出ませんでした。tableと違い、こちらは構造として受け入れられています。ただしdl の直接の子が div に変わるため、dl > dt のような「直接の子」を指定するCSSセレクタが一致しなくなります(一致する数が2から0に変わっています)。dl をgridにして dt と dd を2列に並べるスタイルも、次の節と同じ理由で列の割り当てが変わります。HTMLの構造を変えたくないときは、keyを付けたFragmentを選びます。

flexとgridの子要素でdivを挟むとどうなるか

flexやgridは、直接の子要素を並べる仕組みです。間に div が1つ入ると、その div が1つの子として扱われ、中の要素は並びの対象から外れます。jsdomはレイアウトを計算しないため、ここではPlaywrightでChromiumを起動し、各要素の位置を測りました。

// a3/ok/layout.test.tsx
// @vitest-environment node
import { Fragment } from "react";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { chromium } from "playwright-core";

const log: string[] = [];
const out = (title: string) => { console.log(`--- ${title}\n` + log.join("\n")); log.length = 0; };
const fields = [{ id: "name", label: "名前" }, { id: "mail", label: "メール" }];

test("grid / flex / table を Chromium で測る", async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: 800, height: 600 } });
  log.push("Chromium " + browser.version());
  const measure = async (name: string, node: ReactNode, selector: string) => {
    const html = renderToStaticMarkup(node);
    await page.setContent(`<body style="margin:0;font:16px/20px sans-serif">${html}</body>`);
    const rows = await page.$$eval(selector, (els) => els.map((el) => {
      const r = el.getBoundingClientRect();
      return `${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}(x=${Math.round(r.x)}, y=${Math.round(r.y)}, 幅=${Math.round(r.width)})`;
    }));
    log.push(`${name}\n  HTML: ${html}\n  位置: ${rows.join(" ")}`);
  };

  // grid: ラベル列 100px + 入力列 200px
  const grid = { display: "grid", gridTemplateColumns: "100px 200px", gap: 0 } as const;
  const FieldDiv = ({ f }: { f: { id: string; label: string } }) => <div><label>{f.label}</label><input id={f.id} /></div>;
  const FieldFrag = ({ f }: { f: { id: string; label: string } }) => <><label>{f.label}</label><input id={f.id} /></>;
  await measure("grid + div", <form style={grid}>{fields.map((f) => <FieldDiv key={f.id} f={f} />)}</form>, "label, input");
  await measure("grid + Fragment", <form style={grid}>{fields.map((f) => <FieldFrag key={f.id} f={f} />)}</form>, "label, input");

  // flex: ボタンを横に並べる
  const flex = { display: "flex", gap: 10 } as const;
  const btn = { width: 80, display: "block" } as const;
  const EditDiv = () => <div><button id="edit" style={btn}>編集</button><button id="del" style={btn}>削除</button></div>;
  const EditFrag = () => <><button id="edit" style={btn}>編集</button><button id="del" style={btn}>削除</button></>;
  await measure("flex + div", <nav style={flex}><button id="add" style={btn}>追加</button><EditDiv /></nav>, "button");
  await measure("flex + Fragment", <nav style={flex}><button id="add" style={btn}>追加</button><EditFrag /></nav>, "button");
  await browser.close();
  out("layout");
});

(手元のファイルでは、このあとにtableを同じ方法で読み込む処理が続きます。その結果は前の節に書いたとおりです。)

実行結果:

--- layout
Chromium 141.0.7390.37
grid + div
  HTML: <form style="display:grid;grid-template-columns:100px 200px;gap:0"><div><label>名前</label><input id="name"/></div><div><label>メール</label><input id="mail"/></div></form>
  位置: label(x=0, y=1, 幅=32) input#name(x=0, y=20, 幅=185) label(x=100, y=1, 幅=48) input#mail(x=100, y=20, 幅=185)
grid + Fragment
  HTML: <form style="display:grid;grid-template-columns:100px 200px;gap:0"><label>名前</label><input id="name"/><label>メール</label><input id="mail"/></form>
  位置: label(x=0, y=0, 幅=100) input#name(x=100, y=0, 幅=200) label(x=0, y=21, 幅=100) input#mail(x=100, y=21, 幅=200)
flex + div
  HTML: <nav style="display:flex;gap:10px"><button id="add" style="width:80px;display:block">追加</button><div><button id="edit" style="width:80px;display:block">編集</button><button id="del" style="width:80px;display:block">削除</button></div></nav>
  位置: button#add(x=0, y=0, 幅=80) button#edit(x=90, y=0, 幅=80) button#del(x=90, y=24, 幅=80)
flex + Fragment
  HTML: <nav style="display:flex;gap:10px"><button id="add" style="width:80px;display:block">追加</button><button id="edit" style="width:80px;display:block">編集</button><button id="del" style="width:80px;display:block">削除</button></nav>
  位置: button#add(x=0, y=0, 幅=80) button#edit(x=90, y=0, 幅=80) button#del(x=180, y=0, 幅=80)

位置を表に整理します。

例 divで囲んだ場合 Fragmentの場合
grid(ラベル100px+入力200pxの2列) 「名前」の組が1列目、「メール」の組が2列目に入り、入力欄がラベルの下(y=20)に回る ラベルがx=0、入力欄がx=100に並び、2行(y=0とy=21)になる
flex(ボタン3つを横並び) 「編集」と「削除」が同じx=90で縦に重なる(y=0とy=24) 3つがx=0、90、180と横一列に並ぶ

gridの例では、div が「1つのセル」として扱われたため、ラベルと入力欄の組が丸ごと1列に入りました。意図していた「ラベル列と入力列」にはなっていません。Fragmentにすると、label と input が form の直接の子になり、2列に正しく割り当てられます。

コンポーネントに切り出した途端にレイアウトが変わった、というときは、切り出したコンポーネントの一番外側が div になっていないかを確認します。空タグに変えるだけで、切り出す前と同じHTMLに戻ります。

条件分岐・文字のグループ化で使う

Fragmentは、コンポーネントの一番外側だけでなく、JSXの途中でも使えます。

test("7. 条件分岐と文字のグループ化", () => {
  const Price = ({ value, tax }: { value: number; tax: boolean }) => (
    <p>
      {value}円
      {tax && <>(税込 <strong>{Math.round(value * 1.1)}</strong>円)</>}
    </p>
  );
  log.push(renderToStaticMarkup(<Price value={200} tax={true} />));
  log.push(renderToStaticMarkup(<Price value={200} tax={false} />));
  const Status = ({ ok }: { ok: boolean }) => (ok ? <><dt>状態</dt><dd>完了</dd></> : null);
  log.push(renderToStaticMarkup(<dl><Status ok={true} /><Status ok={false} /></dl>));
  const label: ReactNode = <>必須<sup>※</sup></>;
  log.push(renderToStaticMarkup(<label>{label}名前</label>));
  log.push("空の Fragment: " + JSON.stringify(renderToStaticMarkup(<></>)));
  out("cond");
});

実行結果:

--- cond
<p>200円(税込 <strong>220</strong>円)</p>
<p>200円</p>
<dl><dt>状態</dt><dd>完了</dd></dl>
<label>必須<sup>※</sup>名前</label>
空の Fragment: ""
使い方 コード ポイント
&& で複数の要素を出し分ける {tax && <>(税込 ...)</>} && の右側は1つの式なので、文字とタグをFragmentでまとめる
三項演算子の片側で複数の要素を返す ok ? <>...</> : null 表示しない側は null
文字とタグをまとめて変数に入れる const label = <>必須<sup>※</sup></> span を足さずに、propsや変数として渡せる
何も表示しない <></> または null 出力は空文字。意図が伝わりやすいのは null

配列で返す書き方との使い分け

複数の要素は、配列でも返せます。Fragmentとの違いを確かめます。

test("6. 配列で返す", () => {
  const Arr = () => [<li>a</li>, <li>b</li>];
  const ArrKey = () => [<li key="a">a</li>, <li key="b">b</li>];
  const Frag = () => <><li>a</li><li>b</li></>;
  capture(() => { render(<ul><Arr /></ul>); });
  cleanup();
  const n = log.length;
  capture(() => { render(<ul><ArrKey /></ul>); });
  cleanup();
  capture(() => { render(<ul><Frag /></ul>); });
  log.push(`key 付き配列と Fragment での console.error の数: ${log.length - n}`);
  log.push("配列    : " + renderToStaticMarkup(<ul><ArrKey /></ul>));
  log.push("Fragment: " + renderToStaticMarkup(<ul><Frag /></ul>));
  out("array");
});

実行結果:

--- array
console.error: Each child in a list should have a unique "key" prop.
  Check the top-level render call using <Arr>. It was passed a child from Arr. See https://react.dev/link/warning-keys for more information.
key 付き配列と Fragment での console.error の数: 0
配列    : <ul><li>a</li><li>b</li></ul>
Fragment: <ul><li>a</li><li>b</li></ul>

出力されるHTMLは同じです。違いは書き方にあります。

観点 配列で返す Fragmentで囲む
key 要素ごとに必要(無いと警告) 不要(map の中で使うときだけFragmentに付ける)
区切り 要素の間にカンマが必要 不要。HTMLと同じ感覚で書ける
文字を混ぜる 文字列を引用符で囲む そのまま書ける
向いている場面 データから map で作った一覧をそのまま返す 決まった数の要素を並べて返す

手で並べる要素はFragment、データから作る一覧は map の結果(配列)、と分けると迷いません。

Fragmentの有無を切り替えたときのstate

Fragmentで囲むかどうかを途中で切り替えたとき、中のコンポーネントのstateが保たれるかを確かめます。

test("8. Fragment の有無を切り替えたときの state", () => {
  function Counter() {
    const [n, setN] = useState(0);
    return <button onClick={() => setN(n + 1)}>count:{n}</button>;
  }
  // (a) コンポーネントが返す一番外側で切り替える
  const TopOne = ({ wrap }: { wrap: boolean }) => (wrap ? <><Counter /></> : <Counter />);
  const TopTwo = ({ wrap }: { wrap: boolean }) => (wrap ? <><><Counter /></></> : <Counter />);
  const TopArr = ({ wrap }: { wrap: boolean }) => (wrap ? <><Counter /></> : [<Counter />]);
  // (b) ほかの要素と並んだ位置で切り替える
  const Side = ({ wrap }: { wrap: boolean }) => (
    <section>{wrap ? <><Counter /></> : <Counter />}<hr /></section>
  );
  const cases = [["(a) <><Counter /></> → <Counter />", TopOne], ["(a) <><><Counter /></></> → <Counter />", TopTwo], ["(a) <><Counter /></> → [<Counter />]", TopArr], ["(b) 兄弟がいる位置で <><Counter /></> → <Counter />", Side]] as const;
  for (const [name, C] of cases) {
    const { rerender, unmount } = render(<C wrap={true} />);
    fireEvent.click(screen.getByText(/count/));
    fireEvent.click(screen.getByText(/count/));
    const before = screen.getByText(/count/).textContent;
    rerender(<C wrap={false} />);
    log.push(`${name}: ${before} → ${screen.getByText(/count/).textContent}`);
    unmount();
  }
  out("state");
});

実行結果:

--- state
(a) <><Counter /></> → <Counter />: count:2 → count:2
(a) <><><Counter /></></> → <Counter />: count:2 → count:0
(a) <><Counter /></> → [<Counter />]: count:2 → count:2
(b) 兄弟がいる位置で <><Counter /></> → <Counter />: count:2 → count:0

コンポーネントが返す一番外側では、1段のFragmentを外しても、配列に変えても、stateは保たれました(count:2のまま)。Fragmentを2段重ねた場合と、ほかの要素と並んだ位置で切り替えた場合は、stateが初期値に戻っています。React公式リファレンスのFragmentのページにも、Fragmentの有無や配列との切り替えでstateがリセットされないのは1段の深さまで、という注意書きがあります。stateを持つコンポーネントを囲むFragmentは、条件で付けたり外したりせず、常に同じ形にしておくと安心です。

Fragmentのrefを使う

React 19.3.0では、Fragment にrefを渡せました。refには、Fragmentの中の要素をまとめて扱うためのオブジェクトが入ります。

test("9. Fragment の ref", () => {
  let got: unknown = "not called";
  function App() {
    const ref = useRef<unknown>(null);
    useEffect(() => { got = ref.current; });
    // @ts-ignore 型定義に ref があるかは版による
    return <Fragment ref={ref}><button>a</button><button>b</button></Fragment>;
  }
  capture(() => { render(<App />); });
  const g = got as any;
  log.push(`ref.current: ${g === null ? "null" : typeof g} ${g?.constructor?.name ?? ""}`);
  if (g) {
    log.push("メソッド: " + Object.getOwnPropertyNames(Object.getPrototypeOf(g)).filter((k) => k !== "constructor").join(", "));
    try { log.push("getClientRects().length=" + g.getClientRects().length); } catch (e) { log.push(String(e)); }
    try {
      let clicked = 0; g.addEventListener("click", () => clicked++);
      fireEvent.click(screen.getByText("a")); fireEvent.click(screen.getByText("b"));
      log.push("addEventListener('click') → 2つのボタンを押した回数=" + clicked);
      g.focus(); log.push("focus() 後の activeElement=" + document.activeElement?.textContent);
      g.focusLast(); log.push("focusLast() 後の activeElement=" + document.activeElement?.textContent);
    } catch (e) { log.push(String(e)); }
  }
  log.push("React.version=" + React.version);
  out("ref");
});

実行結果:

--- ref
ref.current: object FragmentInstance
メソッド: addEventListener, removeEventListener, dispatchEvent, focus, focusLast, blur, observeUsing, unobserveUsing, getClientRects, getRootNode, compareDocumentPosition, scrollIntoView
getClientRects().length=0
addEventListener('click') → 2つのボタンを押した回数=2
focus() 後の activeElement=a
focusLast() 後の activeElement=b
React.version=19.3.0

ref.current には FragmentInstance というオブジェクトが入り、警告は出ませんでした。addEventListener で付けたクリックの処理は、Fragmentの中の2つのボタンの両方で動いています。focus() は最初のボタン、focusLast() は最後のボタンにフォーカスを移しました。getClientRects() が0件なのは、jsdomがレイアウトを計算しないためです。

React公式リファレンス(Fragment)に載っているメソッドを、用途ごとに整理します。

用途 メソッド 公式リファレンスの説明
イベント addEventListener、removeEventListener、dispatchEvent Fragmentの1階層目のDOMの子すべてにリスナーを付け外しする
フォーカス focus、focusLast、blur フォーカスできる最初/最後の要素に移す(入れ子の中も順に探す)、フォーカスを外す
監視 observeUsing、unobserveUsing IntersectionObserverやResizeObserverで子要素の監視を始める/やめる
位置・構造 getClientRects、getRootNode、compareDocumentPosition、scrollIntoView 子要素の矩形の取得、位置関係の比較、スクロール

同じリファレンスには、refを渡すときも空タグの書き方は使えず、Fragment をimportして書くこと、scrollIntoView はオプションのオブジェクトを受け取らず真偽値で指定することも書かれています。

これまで「中の要素をまとめて操作したい」ときは、そのためだけに div で囲んでrefを付けるのが定番でした。Fragmentのrefを使うと、HTMLの構造を変えずに同じことができます。 table・flex・gridのように div を足せない場所で、まとめてイベントを付けたい、最初の入力欄にフォーカスしたい、といった場面で役に立ちます。

divとFragmentの選び方

Fragmentは「何でも置き換えるもの」ではなく、div が合う場面もあります。

確認すること はい いいえ
classNameやstyleを付けたいか div などの要素 Fragment
クリックなどのイベントを、まとまり全体で受けたいか div などの要素(HTMLを変えたくなければFragmentのref) Fragment
親がtable・tr・dlなど、子の種類が決まっている要素か Fragment どちらでも可
親がflexやgridで、子を1つずつ並べたいか Fragment まとめて1つの枠にしたいなら div
見出しや区切りとして意味を持つまとまりか section、article、header など意味に合った要素 Fragment

迷ったら「このまとまりに見た目や意味があるか」で決めます。 あれば要素、JSXの決まりで1つにまとめたいだけならFragmentです。

よくある質問

Q. 空タグとFragmentはどちらを使えばよいですか? A. ふだんは短く書ける空タグで十分です。map の中でkeyが必要なときと、refを付けたいときだけ Fragment をimportして使います。出力されるHTMLはどちらも同じです。

Q. mapの中で空タグを使ったら、keyの警告が消えません。 A. 空タグにはkeyを書けないため、map が返す一番外側を Fragment に書き換えて key を付けます。中の要素にkeyを付けても、map が直接返しているのは外側のFragmentなので警告は残ります。

Q. FragmentにclassNameを付けられますか? A. 付けられません。React 19.3.0では、Fragmentが受け取れるのはkey・ref・childrenだけ、という警告が出て、TypeScriptでも型エラーになります。見た目を付けたいまとまりは、div などの要素にします。

まとめ

  • Fragmentは、HTMLにタグを足さずに複数の要素を1つにまとめる仕組み。 空タグ・Fragment・React.Fragment の出力は同じ。
  • ふだんは空タグ、map の中では Fragment にkeyを付ける。 空タグにkeyを書くと構文エラーになる。
  • ルート要素が2つあると JSX expressions must have one parent element。 空タグで囲めば直る。
  • table・dl・flex・gridでは、div を挟むとHTMLの構造や並びが変わる。 Fragmentなら切り出す前と同じHTMLになる。
  • Fragmentに渡せるのはkey・ref・childrenだけ。 classNameやイベントを付けたいときは要素を使う。
  • React 19.3.0では Fragment にrefを渡せ、中の要素へのイベント登録やフォーカス移動をまとめて行える。
  • stateを持つコンポーネントを囲むFragmentは、付け外しせず同じ形に保つ。

参考

  • React公式リファレンス Fragment(https://react.dev/reference/react/Fragment)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React Fragmentの使い方 空タグとkey付きの使い分け」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?