ReactのuseImperativeHandleで親に見せる操作を絞る方法
子コンポーネントが受け取った ref を <textarea> にそのまま渡すと、親は DOM のすべての操作を使えます。検証では、親が value を直接書き換えると、子の state と画面がずれ、送信される内容も画面と食い違いました。useImperativeHandle で「フォーカスする」「空にする」の2つだけを親に見せ、空にする操作を state の更新で書けば、親から入力欄の中身を変える操作は子の state を通るようになります。
筆者は検証環境で、DOM をそのまま渡す子と、useImperativeHandle で操作を絞った子を並べ、同じ操作をして比べました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、jsdom と Chromium 141(Playwright 1.56.1 で操作)で動かしました。GIF への変換には ffmpeg 6.1.1 を使っています。公式リファレンスが例に挙げている Modal を ref ではなく props(isOpen)で開閉する書き方は試していません。
DOM をそのまま渡すと、親が子の state を飛び越えて書き換えられる
検証に使ったのは、コメントの入力欄と文字数、送信ボタンをまとめた部品です。入力欄の中身は state の text で持ち、最初は「猫の手を借りたい」が入っています。React 19 では ref を props として受け取れるので、受け取った ref を <textarea> にそのまま渡しました。
import { useState, type Ref } from 'react';
// 受け取った ref を、そのまま <textarea> に渡す(親に DOM ごと見せる)
export function RawCommentBox({ ref }: { ref?: Ref<HTMLTextAreaElement> }) {
const [text, setText] = useState('猫の手を借りたい');
const [sent, setSent] = useState<string | null>(null);
return (
<div className="box">
<textarea ref={ref} value={text} onChange={(e) => setText(e.target.value)} />
<p className="count">{text.length} / 140</p>
<button type="button" onClick={() => setSent(text)}>送信</button>
{sent !== null && <p className="sent">送信した内容:「{sent}」</p>}
</div>
);
}
親は ref.current に入った <textarea> の value を空にして、入力欄の中身を消します。
// 親が DOM を直接書き換えて、入力欄を空にする
export function RawParent() {
const ref = useRef<HTMLTextAreaElement>(null);
return (
<div className="parent">
<button type="button" className="clear" onClick={() => {
if (ref.current) ref.current.value = '';
}}>親から消す</button>
<RawCommentBox ref={ref} />
</div>
);
}
jsdom で「親から消す」と「送信」を順に押した結果です。
--- DOM をそのまま渡す(RawParent) ---
最初: 入力欄=「猫の手を借りたい」 文字数=8 / 140 (まだ送信していない)
親から消す: 入力欄=「」 文字数=8 / 140 (まだ送信していない)
送信: 入力欄=「猫の手を借りたい」 文字数=8 / 140 送信した内容:「猫の手を借りたい」
入力欄は空になりましたが、文字数は「8 / 140」のままです。子の state の text は変わっていないからです。続けて「送信」を押すと、画面では空だった入力欄に元の文が戻り、その元の文が送信されました。送信で子が描き直されたときに、入力欄の中身が state の値に戻ったと考えられます。
公式ドキュメントの、ref で DOM を操作するときの注意には、フォーカスやスクロールのような、DOM を書き換えない操作なら問題は起きにくく、DOM を手で書き換えると React の変更とぶつかるおそれがある、と書かれています。DOM ごと親に渡すと、こうした書き換えを子の側では止められません。
useImperativeHandle で、見せる操作を2つに絞る
useImperativeHandle の公式リファレンスにあるとおり、本物の DOM は子の中の別の ref で持ち、親には useImperativeHandle で作ったオブジェクトを渡します。親に見せる操作は、focus と clear の2つにしました。
import { useImperativeHandle, useRef, useState, type Ref } from 'react';
// 親に見せる操作は、この2つだけ
export type CommentBoxHandle = {
focus: () => void;
clear: () => void;
};
export function CommentBox({ ref }: { ref?: Ref<CommentBoxHandle> }) {
const [text, setText] = useState('猫の手を借りたい');
const [sent, setSent] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => ({
focus() {
textareaRef.current?.focus();
},
clear() {
setText('');
textareaRef.current?.focus();
},
}), []);
return (
<div className="box">
<textarea ref={textareaRef} value={text} onChange={(e) => setText(e.target.value)} />
<p className="count">{text.length} / 140</p>
<button type="button" onClick={() => setSent(text)}>送信</button>
{sent !== null && <p className="sent">送信した内容:「{sent}」</p>}
</div>
);
}
clear は DOM の value には触らず、setText('') で state を空にします。親は ref.current?.clear() を呼ぶだけです。
// 親は、子が見せた clear() を呼ぶだけ
export function HandleParent() {
const ref = useRef<CommentBoxHandle>(null);
return (
<div className="parent">
<button type="button" className="clear" onClick={() => ref.current?.clear()}>親から消す</button>
<CommentBox ref={ref} />
</div>
);
}
同じ操作をした結果です。文字数も「0 / 140」になり、送信した内容も画面と同じ空の文字列でした。
--- useImperativeHandle で絞る(HandleParent) ---
最初: 入力欄=「猫の手を借りたい」 文字数=8 / 140 (まだ送信していない)
親から消す: 入力欄=「」 文字数=0 / 140 (まだ送信していない)
送信: 入力欄=「」 文字数=0 / 140 送信した内容:「」
Chromium でボタンを実際にクリックしても、同じ結果になりました。上が DOM をそのまま渡す版、下が useImperativeHandle の版です。

依存配列は [] にしています。clear の中で使っている setText は、公式リファレンスの useState の注意点にあるとおり、描き直しても同じ関数のままです。textareaRef も、useRef の公式リファレンスにあるとおり、描き直しても同じオブジェクトが返ります。state の値そのものを読む操作を見せるときは、後の「state を読む操作は、依存配列に入れる」の節のとおり、依存配列に入れます。
親の ref.current に入るのは、作ったオブジェクトだけ
親の ref.current に何が入るかを、jsdom で調べました。
DOM をそのまま渡したときの ref.current: HTMLTextAreaElement
HTMLElement か: true
focus があるか: true
clear があるか: false
value があるか: true
style があるか: true
remove があるか: true
useImperativeHandle のときの ref.current: Object
HTMLElement か: false
持っているプロパティ: focus, clear
focus があるか: true
clear があるか: true
value があるか: false
style があるか: false
remove があるか: false
useImperativeHandle の版では、ref.current はただのオブジェクトで、focus と clear しか持っていません。親から value や style を触ったり、remove() で要素を消したりする手段はなくなります。
TypeScript では、親の useRef に、子の側で定義して export した型(ここでは CommentBoxHandle)を渡しておくと、見せていない操作を呼んだ行を tsc が見つけます。次のコードは、検証用のファイルから親の部分を抜き出したもので、ファイルの11行目と12行目がエラーになりました。
const rawRef = useRef<HTMLTextAreaElement>(null);
const handleRef = useRef<CommentBoxHandle>(null);
const onClick = () => {
rawRef.current?.remove(); // DOM を渡す子には、何でもできてしまう
handleRef.current?.focus();
handleRef.current?.remove();
if (handleRef.current) handleRef.current.style.opacity = '0.5';
};
types/TypeError.tsx(11,24): error TS2339: Property 'remove' does not exist on type 'CommentBoxHandle'.
types/TypeError.tsx(12,46): error TS2339: Property 'style' does not exist on type 'CommentBoxHandle'.
DOM を渡す子への remove() はエラーになりません。ハンドルの型に書いた focus() も通り、書いていない remove() と style だけがエラーになりました。
子の DOM は子の中の ref で持ち、親には useImperativeHandle で必要な操作だけを渡します。入力欄の中身を変える操作は state の更新で書くと、画面と state がずれません。
state を読む操作は、依存配列に入れる
公式リファレンスの引数の説明によると、依存配列の値が変わったとき、または依存配列を省いた場合は描き直すたびに、ハンドルを作る関数がもう一度呼ばれ、新しいハンドルが ref に入ります。入力中の文を返す getText() を見せる部品で、依存配列を変えて比べました。次は、依存配列を [] にした版と [text] にした版です。Props(ref だけを受け取る props の型)と、ハンドルを作った回数を数える created は、ファイルの先頭で定義しています。
// 依存配列が [] … 最初の描画のときだけハンドルを作る
export function EmptyDepsDraft({ ref }: Props) {
const [text, setText] = useState('');
useImperativeHandle(ref, () => {
created.empty++;
return { getText: () => text };
}, []);
return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}
// 依存配列が [text] … text が変わるたびに作り直す
export function TextDepsDraft({ ref }: Props) {
const [text, setText] = useState('');
useImperativeHandle(ref, () => {
created.text++;
return { getText: () => text };
}, [text]);
return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}
依存配列を書かない版も用意しました。「に」「にゃ」と2回入力してから、子の text は変えずに親だけを描き直し、最後に getText() を呼んだ結果です。親が描き直しても同じ関数になるよう、ref のコールバックは親コンポーネントの外で1つだけ作って渡しています。
(操作:「に」と入力 →「にゃ」と入力 → 親だけを描き直す)
--- 依存配列が [] ---
createHandle が呼ばれた回数: 1
ref のコールバックに渡った値: ハンドル
入力欄=「にゃ」 getText()=「」
--- 依存配列が [text] ---
createHandle が呼ばれた回数: 3
ref のコールバックに渡った値: ハンドル → null → ハンドル → null → ハンドル
入力欄=「にゃ」 getText()=「にゃ」
--- 依存配列なし ---
createHandle が呼ばれた回数: 4
ref のコールバックに渡った値: ハンドル → null → ハンドル → null → ハンドル → null → ハンドル
入力欄=「にゃ」 getText()=「にゃ」
[] の版は、入力欄に「にゃ」と入っているのに getText() が空の文字列を返しました。最初の描画のときに作ったハンドルが、そのときの text を持ち続けていると考えられます。[text] の版は、入力のたびにハンドルを作り直し、最新の文を返しました。依存配列を書かない版は、text が変わらない親の描き直しでも作り直しています。ハンドルを作り直すたびに、親の ref のコールバックには null が渡ってから新しいハンドルが渡りました。
props で書けることは、ref で見せない
公式リファレンスの注意は、ref を使いすぎないよう呼びかけています。例として、Modal から { open, close } を見せるより、<Modal isOpen={isOpen} /> のように props で受け取るほうがよい、と書かれています。ref を使うのは、スクロールやフォーカス、アニメーションの開始、文字の選択のように、props では書けない操作だけにするよう勧めています。





