React・仕事で使うために

DOMを直接触る(refとポータル)

Reactに任せない部分の話です。refでDOMを直接触る場面の見分け方、React 19でforwardRefが要らなくなったこと、そしてcreatePortalとdialogでモーダルを作るところまで扱います。

ここまでは「Reactに任せる」話でした。この回は任せない部分です。

Reactは画面の描き直しを引き受けてくれますが、フォーカスを移す・大きさを測る・スクロールするといった操作までは面倒を見ません。そこだけ、DOMを直接触ります。

refはDOMへの取っ手

const inputRef = useRef(null)

<input ref={inputRef} />
<button onClick={() => inputRef.current?.focus()}>入力欄へ</button>

refを付けた要素の実物のDOMinputRef.currentに入ります。?.が要るのは、描かれる前はnullだからです。

refはDOM以外も入れられます。よく使うのはタイマーIDの保管です。

const timerRef = useRef(null)

function start() {
  timerRef.current = window.setTimeout(() => { /* … */ }, 1000)
}
function stop() {
  if (timerRef.current !== null) clearTimeout(timerRef.current)
}

これをstateで持つと、IDを覚えただけで画面が描き直されます。無駄なうえに、次の描画までは古いIDのままです。

React 19:refはただのprops

自分で作った部品にrefを渡したい場面があります。

<SearchInput ref={inputRef} label="さがす" />

React 18まではこれが素通りしなかったので、forwardRefで包む必要がありました。React 19からは、refを普通のpropsとして受け取れます。

function SearchInput({ ref, label }) {
  const id = useId()
  return (
    <p>
      <label htmlFor={id}>{label}</label>
      <input id={id} ref={ref} type="search" />
    </p>
  )
}

ポータル——JSXの位置と、DOMの位置を分ける

モーダルを作ると、必ずこれに当たります。

<div className="overflow-hidden rounded-2xl">
  <BirdCard />
  <Modal />       {/* ← 親のoverflow-hiddenで切られる */}
</div>

親のoverflow: hiddenpositionz-indexに閉じこめられます。CSSで戦っても勝てません。DOM上の位置が悪いからです。

createPortalは、JSXの位置はそのままに、実際のDOMだけを別の場所に置きます

import { createPortal } from 'react-dom'

function Modal({ isOpen, onClose, title, children }) {
  const dialogRef = useRef(null)
  const titleId = useId()

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return
    if (isOpen) dialog.showModal()
    else dialog.close()
  }, [isOpen])

  return createPortal(
    <dialog ref={dialogRef} aria-labelledby={titleId} onClose={onClose} className="rounded-2xl p-6">
      <h2 id={titleId} className="font-bold">{title}</h2>
      {children}
      <button type="button" onClick={onClose}>閉じる</button>
    </dialog>,
    document.body,   // ← 実際に置く場所
  )
}

実際に動かして確かめると、こうなっていました。

確かめたこと結果
<dialog>の親要素BODY(深い場所に書いたのに)
開いたあとのopen属性付いている
フォーカスの位置ダイアログの中(自動で移った)
aria-labelledbyと見出しのidどちらも_r_1_で一致

イベントは元のJSXの位置を伝わります。DOMはbody直下にあるのに、onCloseは書いた場所の親に届きます。「見た目のツリー」と「Reactのツリー」が別、というのがポータルの正体です。

ReactReactでのアクセシビリティアクセシビリティの回。フォーカスを移す話は、この回のrefがそのまま道具になります

公式ドキュメントも見てみる

ja.react.dev外部サイトref で DOM を操作する – ReactReact公式の「refでDOMを操作する」。使ってよい場面と、避けるべき場面の線引きが具体的に書かれています

このレッスンのまとめ

  1. refDOMへの取っ手refを付けた要素の実物がcurrentに入る
  2. 画面に出るならstate、出ないならref。refを変えても再描画されない
  3. タイマーIDのような「覚えておくだけの値」もrefが向いている
  4. React 19ではrefが普通のpropsforwardRefは不要(既存コードでは読めるように)
  5. refにコールバックを渡すと付け外しで呼ばれ、React 19から後片づけを返せる
  6. createPortalはJSXの位置を変えずにDOMだけ移す。親のoverflowz-indexから逃げられる
  7. ポータルでもイベントは元のJSXの位置を伝わる
  8. モーダルは<dialog>showModal()。フォーカスの閉じこめとEscapeが付いてくる

次は、まだ扱っていない残りのフックです。

やってみよう:削除の確認モーダルを作る

詳細ページの「ずかんから消す」に、確認を挟みます。

  1. src/components/Modal.jsxを作り、createPortaldocument.bodyに出す
  2. 中身は<dialog>にして、isOpentrueのときshowModal()を呼ぶ
  3. 見出しのiduseId()で作り、aria-labelledbyでつなぐ
  4. 詳細ページの削除ボタンで開き、「消す」「やめる」の2つを置く
  5. 開いた状態でTabを何度も押して、フォーカスが外に出ないことを確認する
  6. Escapeで閉じることを確認する
return createPortal(
  <dialog ref={dialogRef} aria-labelledby={titleId} onClose={onClose}>

  </dialog>,
  document.body,
)

5番と6番は自分では1行も書いていない動きです。<div>で作っていたら全部自分で書くところだった、というのがこの回の要点です。

よくある質問

refはいつ使いますか?
フォーカスを移す、スクロール位置を変える、要素の大きさを測る、動画を再生するなど、値ではなく操作そのものが目的のときです。表示に関わる値はstateで持ちます。
refに入れた値を変えても画面が変わりません。
仕様どおりです。refは書きかえても再描画のきっかけになりません。画面に出したい値ならstateにします。
forwardRefはもう不要ですか?
React 19からはrefを普通のpropsとして受け取れるので、新しく書くコードでは不要です。既存のコードにはまだ大量に残っているので、読めるようにはしておきます。
モーダルをcreatePortalで出すのはなぜですか?
親の overflow:hidden や position、z-index に閉じこめられないためです。JSXの位置はそのままに、実際のDOMだけをbody直下に置けます。