React 19では、関数コンポーネントが ref をふつうのpropsとして受け取れます。forwardRef で包まなくても、function MyInput({ ref }) と書くだけで、親から渡されたrefを子の中のDOM要素へつなげられます。 React 18までは、関数コンポーネントに ref を渡すと警告が出て値も届かなかったため、forwardRef で包む必要がありました。

この記事では、同じコンポーネントを「forwardRefあり(18の書き方)」と「ref props(19の書き方)」で、JavaScript版とTypeScript版の両方を並べて示します。 あわせて、React 18.3.1で実際に出る警告文、useImperativeHandle で親に見せるメソッドを絞る例、refコールバックのクリーンアップ関数の呼ばれる順番、複数のrefを1つにまとめる関数、型の修正を自動で行うcodemodの差分を、すべて動かした出力つきで載せます。

確認環境:Ubuntu 24.04、Node.js 22.22.2、React 19.3.0 / react-dom 19.3.0(比較用に React 18.3.1)、TypeScript 7.0.2、@types/react 19.3.0。esbuild 0.28.2でまとめ、jsdom 30.1.1の上で開発ビルドを動かしました。

refの渡し方の早見表(目的別)

やりたいこと React 19の書き方 要点
子のDOMに触る(フォーカス、スクロール、サイズ計測) 子で function MyInput({ ref }) と受け、<input ref={ref} /> に渡す forwardRef は不要
子のメソッドだけを呼ぶ(DOM全体は見せない) 子で useImperativeHandle(ref, () => ({ focus, clear }), []) 親の ref.current には公開した関数だけが入る
子でも親でも同じDOMを使う(複数のrefをまとめる) refコールバックで両方に代入する mergeRefs(localRef, ref) クリーンアップ関数を返して片付ける
DOMが付いた・外れたときに処理を走らせる ref={(node) => { 登録; return () => 片付け; }} 19ではクリーンアップ関数を返せる
何段も下の子のDOMに触る 途中のコンポーネントは ref をそのまま下へ渡す ふつうのpropsと同じ受け渡し
18の書き方を残したまま19へ上げる forwardRef のまま動く 19.3.0では警告も出ない
型の修正をまとめて行う npx types-react-codemod@latest preset-19 ./src useRef() の引数や暗黙のreturnを書き換える
項目 React 18まで React 19
関数コンポーネントで ref を受ける forwardRef((props, ref) => ...) function C({ ref, ...props })
propsのキーに ref が入るか 入らない(label だけ) 入る(label, ref)
TypeScriptの型 forwardRef<HTMLInputElement, Props> ref?: Ref<HTMLInputElement> をpropsの型に足す
DOMの属性ごと受ける型 ComponentPropsWithoutRef<'input'> と forwardRef ComponentPropsWithRef<'input'>
refコールバックの片付け 外れるときに null で呼ばれる クリーンアップ関数を返せる(返したら null 呼び出しは省かれる)
useRef の引数(TypeScript) 省略できた 必須(useRef(null) / useRef(undefined))

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

(記載例)出典:株式会社RJC「React 19のref propsとforwardRef移行 書き換え例」

React 19でrefをpropsとして受け取る基本形

まずは完成形です。子の MyInput は ref を分割代入で受け取り、そのまま input に渡します。親はボタンを押したときに、子の中の入力欄へフォーカスを移します。

// a1.jsx(React 19.3.0)
import { useRef } from 'react';
import { createRoot } from 'react-dom/client';

// 子:ref を props として受け取り、input に渡す
function MyInput({ label, ref }) {
  return (
    <label>
      {label}
      <input id="name" ref={ref} />
    </label>
  );
}

// 親:ボタンを押したら子の input にフォーカスする
function Form() {
  const inputRef = useRef(null);
  return (
    <>
      <MyInput label="名前" ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>入力欄へ移動</button>
    </>
  );
}

createRoot(document.getElementById('root')).render(<Form />);

ボタンをクリックする前後で document.activeElement(いまフォーカスのある要素)を出力した結果です。

クリック前: BODY
クリック後: INPUT #name

親の inputRef.current に子の input 要素が入り、focus() が効いています。 子の側で特別なAPIは使っていません。ref は label と同じ並びで受け取るだけです。

公式ブログ「React v19」の「ref as a prop」には、新しい関数コンポーネントでは forwardRef が要らなくなること、将来のバージョンで forwardRef を非推奨にして取り除く予定であることが書かれています。また、クラスコンポーネントに渡した ref はコンポーネントのインスタンスを指すため、propsには入らないとも書かれています。

React 18で関数コンポーネントにrefを渡したときの警告文

React 18.3.1で、forwardRef なしの関数コンポーネントに ref を渡すとどうなるかを確かめます。比べるために、forwardRef ありのコンポーネントも並べました。

// w1.jsx(React 18.3.1)
import { useRef, forwardRef } from 'react';
import { createRoot } from 'react-dom/client';

// forwardRef なしの関数コンポーネントに ref を渡す
function MyInput(props) {
  console.log('props のキー:', Object.keys(props));
  return <input id="plain" ref={props.ref} />;
}
// forwardRef あり
const FwdInput = forwardRef(function FwdInput({ label }, ref) {
  return <input id="fwd" ref={ref} />;
});

function App() {
  const a = useRef(null), b = useRef(null);
  return (
    <>
      <MyInput label="A" ref={a} />
      <FwdInput label="B" ref={b} />
      <button onClick={() =>
        console.log('a.current:', a.current, ' b.current:', b.current && b.current.id)
      }>確認</button>
    </>
  );
}

createRoot(document.getElementById('root')).render(<App />);

実行結果(開発ビルド。スタックトレースのファイルパスは省いています):

React 18.3.1
props のキー: [ 'label' ]
a.current: null  b.current: fwd
[console.error 1]
Warning: MyInput: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)
    at MyInput
    at App
[console.error 2]
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

Check the render method of `App`.
    at MyInput
    at App

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

  • 18ではpropsのキーに ref が入りません(label だけ)。子が props.ref を読むと1つめの警告が出ます。
  • 関数コンポーネントに ref を渡したこと自体に対して、2つめの警告「Function components cannot be given refs.」が出ます。
  • 親の a.current は null のままで、forwardRef で包んだほうの b.current だけが要素を指しています。

同じ形のコードをReact 19.3.0で動かすと、次のようになります。

props のキー: [ 'label', 'ref' ]
a: old  b: dump
console.error の件数: 0

19ではpropsのキーに ref が入り、警告は0件です。 この確認では forwardRef で包んだコンポーネント(a)と、propsで受けたコンポーネント(b)を同じ画面に置いています。どちらも要素を取得でき、forwardRef を残したコンポーネントも19.3.0でそのまま動きました。段階的に書き換えられるということです。

forwardRefからref propsへの書き換え(JavaScript版)

書き換えは3手で終わります。

手順 すること
1 forwardRef(...) の包みを外し、ふつうの関数宣言にする
2 第2引数の ref を、第1引数(props)の分割代入へ移す
3 使わなくなった forwardRef のimportを消す
// 書き換え前:React 18までの書き方
import { forwardRef } from 'react';

const MyInput = forwardRef(function MyInput({ label }, ref) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
});

export default MyInput;
// 書き換え後:React 19の書き方
function MyInput({ label, ref }) {
  return (
    <label>
      {label}
      <input ref={ref} />
    </label>
  );
}

export default MyInput;

呼び出す側(<MyInput ref={inputRef} />)は1文字も変わりません。 そのため、コンポーネント単位で少しずつ書き換えていけます。

残りのpropsをまとめて下へ渡している場合は、次の点だけ気を付けます。19では ref もpropsの一員なので、{...props} と書けば ref も一緒に渡ります。

// ref を取り出さずに、残りの props ごと input へ渡す形でも届く
function MyInput({ label, ...rest }) {
  return (
    <label>
      {label}
      <input {...rest} />
    </label>
  );
}

この形で、親の ref.current に input 要素が入ることを確かめました。ref を別の要素に付けたいとき(外側の label ではなく内側の input に付けたい、など)は、最初の例のように ref を名前で取り出して、付けたい要素に渡します。

TypeScript版の書き換え:Ref型とComponentPropsWithRef

TypeScriptでは、型の書き場所が変わります。18までは forwardRef の型引数に「refの型、propsの型」の順で書いていました。19ではpropsの型に ref?: Ref<要素の型> を1行足すだけです。

// ts/ok.tsx(TypeScript 7.0.2、@types/react 19.3.0)
import { forwardRef, useImperativeHandle, useRef } from 'react';
import type { ComponentPropsWithRef, ComponentPropsWithoutRef, Ref } from 'react';

// ① React 18 までの書き方:forwardRef<refの型, propsの型>
type OldProps = { label: string };
export const OldInput = forwardRef<HTMLInputElement, OldProps>(
  function OldInput({ label }, ref) {
    return <label>{label}<input ref={ref} /></label>;
  }
);

// ② React 19:props の型に ref?: Ref<T> を足す
type NewProps = { label: string; ref?: Ref<HTMLInputElement> };
export function NewInput({ label, ref }: NewProps) {
  return <label>{label}<input ref={ref} /></label>;
}

// ③ React 19:input の属性を丸ごと受けたいときは ComponentPropsWithRef
type TextFieldProps = ComponentPropsWithRef<'input'> & { label: string };
export function TextField({ label, ref, ...rest }: TextFieldProps) {
  return <label>{label}<input ref={ref} {...rest} /></label>;
}

// ④ 公開するメソッドを絞る:ref の型は「ハンドルの型」にする
export type SearchBoxHandle = { focus(): void; clear(): void };
type SearchBoxProps = ComponentPropsWithoutRef<'input'> & { ref?: Ref<SearchBoxHandle> };
export function SearchBox({ ref, ...rest }: SearchBoxProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  useImperativeHandle(ref, () => ({
    focus() { inputRef.current?.focus(); },
    clear() { if (inputRef.current) inputRef.current.value = ''; },
  }), []);
  return <input ref={inputRef} {...rest} />;
}

export function Page() {
  const a = useRef<HTMLInputElement>(null);
  const b = useRef<HTMLInputElement>(null);
  const c = useRef<HTMLInputElement>(null);
  const box = useRef<SearchBoxHandle>(null);
  return (
    <>
      <OldInput label="A" ref={a} />
      <NewInput label="B" ref={b} />
      <TextField label="C" ref={c} placeholder="入力" maxLength={20} />
      <SearchBox ref={box} defaultValue="react" />
      <button onClick={() => { box.current?.clear(); box.current?.focus(); }}>クリア</button>
    </>
  );
}

このファイルは tsc --noEmit をエラーなしで通りました(tsconfigは strict: true、jsx: "react-jsx")。

型の選び方

場面 使う型 例
自前のpropsにrefだけ足す Ref ref?: Ref<HTMLInputElement>
DOM要素の属性を丸ごと受け、refもその要素へ渡す ComponentPropsWithRef ComponentPropsWithRef<'input'> & { label: string }
DOM要素の属性は受けるが、refは別の型にしたい ComponentPropsWithoutRef に ref を足す ComponentPropsWithoutRef<'input'> & { ref?: Ref<SearchBoxHandle> }
親で作るref useRef に要素の型と null useRef<HTMLInputElement>(null)
18の書き方(そのまま動く) forwardRef の型引数 forwardRef<HTMLInputElement, OldProps>(...)

Ref は「refオブジェクト、refコールバック、null」をまとめた型です。子が受け取る側は Ref、親が useRef で作る側は RefObject と覚えると迷いません。 受け取る側を RefObject にしてしまうと、親がrefコールバックを渡したときに型エラーになります。

型エラーになる例と実際のメッセージ

型が合わないと、どんなメッセージが出るかを確かめます。

// ts/ng.tsx
import { useRef } from 'react';
import type { Ref } from 'react';
import { SearchBox } from './ok';
import type { SearchBoxHandle } from './ok';

// (1) props の型に ref を書いていない
function NoRef({ label }: { label: string }) {
  return <span>{label}</span>;
}
// (2) input 用の ref を受け取るコンポーネント
function NewInput({ ref }: { ref?: Ref<HTMLInputElement> }) {
  return <input ref={ref} />;
}

export function Page() {
  const inputRef = useRef<HTMLInputElement>(null);
  const divRef = useRef<HTMLDivElement>(null);
  const box = useRef<SearchBoxHandle>(null);
  return (
    <>
      <NoRef label="A" ref={inputRef} />
      <NewInput ref={divRef} />
      <SearchBox ref={box} />
      <button onClick={() => console.log(box.current?.value)}>値</button>
    </>
  );
}

tsc --noEmit の出力:

ts/ng.tsx(21,24): error TS2322: Type '{ label: string; ref: RefObject<HTMLInputElement | null>; }' is not assignable to type 'IntrinsicAttributes & { label: string; }'.
  Property 'ref' does not exist on type 'IntrinsicAttributes & { label: string; }'.
ts/ng.tsx(22,17): error TS2322: Type 'RefObject<HTMLDivElement | null>' is not assignable to type 'Ref<HTMLInputElement> | undefined'.
  Type 'RefObject<HTMLDivElement | null>' is not assignable to type 'RefObject<HTMLInputElement | null>'.
    Type 'HTMLDivElement | null' is not assignable to type 'HTMLInputElement | null'.
      Type 'HTMLDivElement' is missing the following properties from type 'HTMLInputElement': accept, alt, autocomplete, capture, and 54 more.
ts/ng.tsx(24,55): error TS2339: Property 'value' does not exist on type 'SearchBoxHandle'.
エラー 起きていること 直し方
TS2322 Property 'ref' does not exist 子のpropsの型に ref が無い 子の型に ref?: Ref<要素の型> を足す
TS2322 RefObject<HTMLDivElement | null> が合わない 親のrefの要素型と、子が期待する要素型が違う 親を useRef<HTMLInputElement>(null) に合わせる
TS2339 Property 'value' does not exist 公開していないものを親が読もうとしている 必要なら子のハンドルにメソッドを足す

3つめは、次の章で扱う useImperativeHandle の効果です。親から見える範囲が型でも守られます。

useImperativeHandleで親に見せるメソッドを絞る

ref をそのまま input に渡すと、親は input 要素のすべて(value の書き換え、style の変更、remove() など)を操作できます。「フォーカスを当てる」と「中身を消す」だけを許したい、というときは useImperativeHandle で公開する関数を自分で決めます。 19では forwardRef なしで、propsの ref をそのまま第1引数に渡せます。

// a3.jsx(React 19.3.0)
import { useRef, useImperativeHandle } from 'react';
import { createRoot } from 'react-dom/client';

// 子:外に見せるのは focus と clear の2つだけ
function SearchBox({ ref }) {
  const inputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    focus() { inputRef.current.focus(); },
    clear() { inputRef.current.value = ''; },
  }), []);
  return <input id="q" ref={inputRef} defaultValue="react" />;
}

function Page() {
  const boxRef = useRef(null);
  return (
    <>
      <SearchBox ref={boxRef} />
      <button onClick={() => {
        console.log('公開されているもの:', Object.keys(boxRef.current));
        console.log('value が見えるか:', 'value' in boxRef.current);
        boxRef.current.clear();
        boxRef.current.focus();
      }}>クリアして移動</button>
    </>
  );
}

createRoot(document.getElementById('root')).render(<Page />);

ボタンを押す前後で入力欄の値とフォーカス先を出力した結果です。

クリック前の値: "react"
公開されているもの: [ 'focus', 'clear' ]
value が見えるか: false
クリック後の値: ""
フォーカス先: #q

親の boxRef.current に入っているのは focus と clear だけで、value は見えません。 子の中のDOMは子の inputRef が持ち続け、親は決められた入口だけを使います。

useImperativeHandle の引数は次の3つです。

引数 内容
第1引数 ref 親から受け取ったref(19ではpropsの ref)
第2引数 createHandle 親に見せるオブジェクトを返す関数
第3引数 dependencies 第2引数の中で使っているpropsやstateの配列。変わったときにハンドルを作り直す

使いどころは、フォーカス、スクロール、アニメーションの開始、テキストの選択のように「propsでは表しにくい、その場の操作」です。開閉の状態のようにpropsで表せるものは、isOpen のようなpropsで渡すほうがデータの流れを追いやすくなります。

refコールバックのクリーンアップ関数

ref には、オブジェクトの代わりに関数(refコールバック)も渡せます。要素がDOMに付いたときに、その要素を引数にして呼ばれます。React 19では、このrefコールバックからクリーンアップ関数を返せるようになりました。 useEffect と同じ形で「登録」と「片付け」を1か所に書けます。

// a4.jsx(React 19.3.0)
import { useState } from 'react';
import { createRoot } from 'react-dom/client';

function Panel() {
  const [show, setShow] = useState(true);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>切り替え</button>
      {show && (
        <>
          {/* A:クリーンアップ関数を返さない(従来どおり) */}
          <div id="a" ref={(node) => { console.log('A 呼び出し:', node ? node.id : null); }} />
          {/* B:クリーンアップ関数を返す(React 19) */}
          <div id="b" ref={(node) => {
            console.log('B 登録:', node.id);
            const onClick = () => console.log('B がクリックされた');
            node.addEventListener('click', onClick);
            return () => {
              console.log('B 片付け:', node.id);
              node.removeEventListener('click', onClick);
            };
          }} />
        </>
      )}
    </>
  );
}

createRoot(document.getElementById('root')).render(<Panel />);

マウント、非表示、再表示の順に操作した結果です。

--- マウント
A 呼び出し: a
B 登録: b
--- 非表示にする
A 呼び出し: null
B 片付け: b
--- 再び表示する
A 呼び出し: a
B 登録: b
書き方 要素が外れるとき 向いている場面
A:何も返さない 同じ関数が null を引数にして呼ばれる 変数に要素を入れておくだけの処理
B:クリーンアップ関数を返す 返した関数だけが呼ばれ、null 呼び出しは省かれる イベントの登録と解除、ResizeObserver や IntersectionObserver の開始と停止

Bの書き方では node が null になる場合を考えなくてよいため、node.id をそのまま読めています。 公式ブログには、将来のバージョンで「外れるときに null で呼ぶ」動作を非推奨にする予定だと書かれています。新しく書くrefコールバックはBの形にそろえておくと、あとの書き換えが少なくなります。

再レンダーのたびに片付けと登録が走る場合

refコールバックをJSXの中に直接書くと、レンダーのたびに新しい関数が作られます。Reactは「refが別のものに変わった」と判断し、前の片付けと新しい登録を行います。

// a6.jsx(React 19.3.0)
import { useState, useCallback } from 'react';
import { createRoot } from 'react-dom/client';

function Counter() {
  const [n, setN] = useState(0);
  // A:毎回新しい関数を渡す
  const inlineRef = (node) => {
    console.log('  A 登録');
    return () => console.log('  A 片付け');
  };
  // B:useCallback で同じ関数を渡し続ける
  const stableRef = useCallback((node) => {
    console.log('  B 登録');
    return () => console.log('  B 片付け');
  }, []);
  return (
    <>
      <div ref={inlineRef} />
      <div ref={stableRef} />
      <button onClick={() => setN(n + 1)}>+1({n})</button>
    </>
  );
}

createRoot(document.getElementById('root')).render(<Counter />);
--- マウント
  A 登録
  B 登録
--- 再レンダー(ボタンを押す)
  A 片付け
  A 登録
--- アンマウント
  A 片付け
  B 片付け

Aは再レンダーのたびに「片付け → 登録」が走り、useCallback で包んだBはマウントとアンマウントの1回ずつです。 変数に要素を入れるだけならAで困りませんが、オブザーバーの開始や重めの初期化を行うときはBの形にします。

TypeScriptでは暗黙のreturnがエラーになる

クリーンアップ関数を返せるようになったことで、TypeScriptでは「refコールバックが関数以外の値を返す」書き方が型エラーになります。アロー関数の本体を丸かっこで書くと、代入した値がそのまま戻り値になるためです。

// エラーになる書き方:代入の結果(要素)を返している
<div ref={(current) => (instance = current)} />

// 通る書き方:波かっこで囲み、何も返さない
<div ref={(current) => { instance = current; }} />

実際のエラー文は、次のcodemodの章で確かめます。

複数のrefを1つの要素にまとめる

「子が自分でも要素を使いたい(サイズを測るなど)、同時に親にも同じ要素を渡したい」という場面では、1つの要素に2つのrefを付けたくなります。ref 属性は1つしか書けないので、refコールバックの中で両方に代入する小さな関数を用意します。

// a5.jsx(React 19.3.0)
import { useRef, useEffect } from 'react';
import { createRoot } from 'react-dom/client';

// 複数の ref を1つの ref コールバックにまとめる
function mergeRefs(...refs) {
  return (node) => {
    const cleanups = refs.map((ref) => {
      if (typeof ref === 'function') {
        const cleanup = ref(node);
        // クリーンアップ関数が返らなかった ref には null を渡して片付ける
        return typeof cleanup === 'function' ? cleanup : () => ref(null);
      }
      if (ref != null) {
        ref.current = node;
        return () => { ref.current = null; };
      }
      return () => {};
    });
    return () => cleanups.forEach((fn) => fn());
  };
}

// 子:自分でも要素を使いたいし、親にも DOM を渡したい
function Card({ ref, children }) {
  const localRef = useRef(null);
  useEffect(() => {
    console.log('子の localRef:', localRef.current.tagName, localRef.current.id);
  }, []);
  return <section id="card" ref={mergeRefs(localRef, ref)}>{children}</section>;
}

function App() {
  const objRef = useRef(null);
  useEffect(() => {
    console.log('親の objRef  :', objRef.current.tagName, objRef.current.id);
    return () => console.log('親の後片付け時:', objRef.current);
  }, []);
  return (
    <Card ref={mergeRefs(objRef, (n) => { console.log('親のコールバック ref:', n ? n.id : null); })}>
      本文
    </Card>
  );
}

const root = createRoot(document.getElementById('root'));
root.render(<App />);
// 確認のため、あとで root.unmount() を呼ぶ
親のコールバック ref: card
子の localRef: SECTION card
親の objRef  : SECTION card
--- アンマウント
親のコールバック ref: null
親の後片付け時: null

子の localRef、親の objRef、親のrefコールバックの3つすべてに同じ section 要素が届き、アンマウント時にはそれぞれ片付いています。 mergeRefs は次の3種類を受け付けるように書いてあります。

渡されたもの 付いたとき 外れるとき
refオブジェクト(useRef の戻り値) ref.current = node ref.current = null
refコールバック(クリーンアップ関数を返す) ref(node) を呼ぶ 返ってきた関数を呼ぶ
refコールバック(何も返さない) ref(node) を呼ぶ ref(null) を呼ぶ
null や undefined(親がrefを渡さなかった) 何もしない 何もしない

前の章で見たとおり、mergeRefs(...) をJSXの中で直接呼ぶと再レンダーのたびに付け直しが起きます。重い処理を含むrefをまとめるときは、const merged = useMemo(() => mergeRefs(localRef, ref), [ref]) のように関数を保持して ref={merged} と渡します。この形で子を再レンダーしても、親のrefコールバックが呼び直されないことを確かめました。

何段も下のコンポーネントへrefを渡す

ref がふつうのpropsになったので、途中のコンポーネントは受け取った ref をそのまま下へ渡すだけです。

function FormField({ label, ref }) {
  return (
    <div className="field">
      <MyInput label={label} ref={ref} />
    </div>
  );
}

親から FormField に渡したrefで、2段下の input 要素を取得できました。18までは FormField と MyInput の両方を forwardRef で包む必要がありました。19では、どちらもふつうの関数です。なお、refで子の内部のDOMを直接さわる箇所が増えると、コンポーネントどうしの結び付きが強くなります。フォーカスやスクロールのような用途に絞って使うと、あとの変更が楽になります。

codemodで型の修正をまとめて行う

React 19 アップグレードガイドには、TypeScriptの型の変更をまとめて書き換えるコマンドとして npx types-react-codemod@latest preset-19 ./path-to-app が載っています。ref関連では、次の3つの変換が含まれます。

変換名 書き換える内容
useRef-required-initial 引数なしの useRef() を useRef(undefined) にする
no-implicit-ref-callback-return refコールバックの暗黙のreturnを、波かっこのブロックにする
refobject-defaults RefObject<T> を RefObject<T | null> にする

18の頃の書き方を含むファイルを用意して、実際に動かしました。

// src/Measure.tsx(書き換え前)
import { useRef } from 'react';
import type { MutableRefObject, RefObject } from 'react';

export function Measure() {
  const timer = useRef<number>();
  const box: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
  let instance: HTMLDivElement | null = null;
  return (
    <div ref={box}>
      <div ref={(current) => (instance = current)} />
      <button onClick={() => { timer.current = 1; console.log(instance); }}>計測</button>
    </div>
  );
}

export function focus(ref: RefObject<HTMLInputElement>) {
  ref.current?.focus();
}

このファイルを @types/react 19.3.0 で型チェックすると、次のエラーが出ます。

before.tsx(5,17): error TS2554: Expected 1 arguments, but got 0.
before.tsx(10,12): error TS2322: Type '(current: HTMLDivElement | null) => HTMLDivElement | null' is not assignable to type 'Ref<HTMLDivElement> | undefined'.
  Type '(current: HTMLDivElement | null) => HTMLDivElement | null' is not assignable to type '(instance: HTMLDivElement | null) => void | (() => VoidOrUndefinedOnly)'.
    Type 'HTMLDivElement | null' is not assignable to type 'void | (() => VoidOrUndefinedOnly)'.
      Type 'null' is not assignable to type 'void | (() => VoidOrUndefinedOnly)'.

1つめが useRef() の引数、2つめがrefコールバックの暗黙のreturnです。codemodを実行します(types-react-codemod 3.5.3)。

npx types-react-codemod@latest preset-19 ./src --yes
Processing 1 files...
Spawning 1 workers...
Sending 1 files to free worker...
All done.
Results:
0 errors
0 unmodified
0 skipped
1 ok
Time elapsed: 0.511seconds

書き換え前後の差分(diff -u)です。

 export function Measure() {
-  const timer = useRef<number>();
+  const timer = useRef<number>(undefined);
   const box: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
   let instance: HTMLDivElement | null = null;
   return (
     <div ref={box}>
-      <div ref={(current) => (instance = current)} />
+      <div ref={current => {
+        (instance = current);
+      }} />
       <button onClick={() => { timer.current = 1; console.log(instance); }}>計測</button>
     </div>
   );
 }

-export function focus(ref: RefObject<HTMLInputElement>) {
+export function focus(ref: RefObject<HTMLInputElement | null>) {
   ref.current?.focus();
 }

書き換え後のファイルは tsc --noEmit をエラーなしで通りました。 3つの変換がそれぞれ1か所ずつ効いています。--yes は確認の質問を省くオプションで、付けない場合は適用する変換を選ぶ画面が出ます。実行前にGitでコミットしておくと、差分を確認しやすくなります。

forwardRefを外すcodemod

forwardRef そのものを外す変換は、reactjs/react-codemod のREADMEに remove-forward-ref として載っており、コマンドは次のとおりです。説明は「Removes usages of forwardRef.」の1行です。

npx codemod react/19/remove-forward-ref --target <path>

このコマンドは、実行時に変換の本体をcodemodのレジストリ(app.codemod.com)から取得します。社内ネットワークなどでレジストリへ届かない場合は、この記事の「書き換え(JavaScript版)」の3手順で手で直せます。1コンポーネントあたり数行の変更で、呼び出す側は変わらないため、ファイル単位で進められます。

アップグレードガイドには、React 19向けの変換をまとめて行う npx codemod@latest react/19/migration-recipe も載っています。こちらに含まれるのは replace-reactdom-render、replace-string-ref、replace-act-import、replace-use-form-state、prop-types-typescript の5つで、ref関連では文字列ref(ref="input")の置き換えが対象です。

移行の進め方

順番 すること 確認方法
1 React・react-dom・@types/react を19へ上げる 画面が動くこと。forwardRef はそのまま動く
2 types-react-codemod preset-19 で型を直す tsc --noEmit が通ること
3 新しく書くコンポーネントは ref をpropsで受ける レビューで形をそろえる
4 既存の forwardRef を、触るファイルから順に外す 呼び出す側は変えずに動くこと
5 refコールバックをクリーンアップ関数を返す形にそろえる 外れるときの処理が1回ずつ呼ばれること

1と2を先に済ませれば、3以降は急がずに進められます。 19.3.0では forwardRef を使っていても警告は出ないため、変更の多い画面から順に書き換える進め方で十分です。

つまずきやすい点と直し方

症状 原因 直し方
19にしたのに ref.current が null のまま 子が ref を受け取っているが、要素に渡していない 子の中で <input ref={ref} /> のように渡す
条件付きで描画する要素にrefを付けたら null 要素がまだ描画されていない 読む前に ref.current?.focus() のように確かめる
TS2322 Property 'ref' does not exist 子のpropsの型に ref が無い ref?: Ref<要素の型> を足す
TS2554 Expected 1 arguments, but got 0 useRef() に引数が無い useRef(null) か useRef(undefined) にする
refコールバックが再レンダーのたびに呼ばれる 毎回新しい関数を渡している useCallback で関数を保持する
クラスコンポーネントで this.props.ref が空 クラスへのrefはインスタンスを指し、propsには入らない DOMを渡したいときは inputRef など別名のpropsにする

よくある質問

Q. React 19にしたら、forwardRefはすぐ書き換える必要がありますか? A. すぐでなくて構いません。React 19.3.0で forwardRef を使ったコンポーネントを動かしたところ、refは届き、警告も出ませんでした。公式ドキュメントには、将来のリリースで非推奨にする予定と書かれているので、新しく書くコンポーネントからref propsにし、既存のものは触るついでに書き換えていくのがよい進め方です。

Q. refの名前を inputRef などの別名にしていたpropsは、ref に戻したほうがよいですか? A. どちらでも動きます。18の頃に forwardRef を避けるために別名のpropsを使っていた場合、19では ref という名前のまま受け取れるので、新しいコードでは ref にそろえると呼び出す側がわかりやすくなります。1つのコンポーネントが複数の要素を外に見せたい場合は、inputRef と buttonRef のように別名のpropsを使い分けるのが自然です。

Q. useImperativeHandleとref propsはどう使い分けますか? A. 親にDOM要素そのものを渡してよいなら、ref を要素にそのまま渡します。親にさせたい操作を数個に絞りたいなら、useImperativeHandle で関数だけを公開します。今回の例では、公開したのは focus と clear の2つで、親から value は見えませんでした。

Q. refコールバックのクリーンアップ関数とuseEffectは、どちらを使えばよいですか? A. 「特定の要素が付いたとき・外れたとき」に合わせたい処理はrefコールバック、「propsやstateの変化」に合わせたい処理は useEffect が向いています。条件付きで描画される要素やリストの各要素では、要素が付いた瞬間に呼ばれるrefコールバックのほうが、要素の有無を確かめる分岐を書かずに済みます。

まとめ

  • React 19では ref をpropsとして受け取れる。 function MyInput({ ref }) と書き、要素の ref に渡すだけでよい。
  • React 18.3.1では、関数コンポーネントに ref を渡すと2つの警告が出て、ref.current は null のまま。 19.3.0ではpropsのキーに ref が入り、警告は0件だった。
  • 書き換えは「forwardRef を外す → ref をpropsの分割代入へ移す → importを消す」の3手。 呼び出す側は変わらない。
  • TypeScriptでは、propsの型に ref?: Ref<要素の型> を足す。 DOMの属性ごと受けるなら ComponentPropsWithRef<'input'>。
  • 親に見せる操作を絞るなら useImperativeHandle。 公開した関数だけが ref.current に入り、型でも守られる。
  • refコールバックはクリーンアップ関数を返せる。 返した場合、外れるときの null 呼び出しは省かれる。毎回新しい関数を渡すと再レンダーのたびに付け直しが起きるので、必要に応じて useCallback で保持する。
  • 型の修正は types-react-codemod preset-19 でまとめて行える。 useRef() の引数、暗黙のreturn、RefObject の型が書き換わることを差分で確かめた。

参考・出典

  • React 公式ブログ「React v19」(ref as a prop、Cleanup functions for refs):https://react.dev/blog/2024/12/05/react-19
  • React 公式「React 19 Upgrade Guide」(codemodのコマンド、useRefの引数、refコールバックの戻り値):https://react.dev/blog/2024/04/25/react-19-upgrade-guide
  • React 公式リファレンス「forwardRef」:https://react.dev/reference/react/forwardRef
  • reactjs/react-codemod README(remove-forward-ref):https://github.com/reactjs/react-codemod

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

(記載例)出典:株式会社RJC「React 19のref propsとforwardRef移行 書き換え例」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?