- React
React 19のref propsとforwardRef移行 書き換え例
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期連続の黒字決算。