ReactのuseImperativeHandleで親に見せる操作を絞る方法

​​React​ref,TypeScript,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> にそのまま渡しました。

tsx
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 を空にして、入力欄の中身を消します。

tsx
// 親が 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 で「親から消す」と「送信」を順に押した結果です。

text
--- 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つにしました。

tsx
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() を呼ぶだけです。

tsx
// 親は、子が見せた 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」になり、送信した内容も画面と同じ空の文字列でした。

text
--- useImperativeHandle で絞る(HandleParent) ---
最初: 入力欄=「猫の手を借りたい」 文字数=8 / 140 (まだ送信していない)
親から消す: 入力欄=「」 文字数=0 / 140 (まだ送信していない)
送信: 入力欄=「」 文字数=0 / 140 送信した内容:「」

Chromium でボタンを実際にクリックしても、同じ結果になりました。上が DOM をそのまま渡す版、下が useImperativeHandle の版です。

上の版では「親から消す」の後も文字数が8のままで、「送信」を押すと元の文が戻って送信される。下の版では文字数が0になり、空のまま送信される
「親から消す」と「送信」を順にクリックしたときの、入力欄と文字数の変わり方(上が DOM をそのまま渡す版、下が useImperativeHandle の版)

依存配列は [] にしています。clear の中で使っている setText は、公式リファレンスの useState の注意点にあるとおり、描き直しても同じ関数のままです。textareaRef も、useRef の公式リファレンスにあるとおり、描き直しても同じオブジェクトが返ります。state の値そのものを読む操作を見せるときは、後の「state を読む操作は、依存配列に入れる」の節のとおり、依存配列に入れます。

親の ref.current に入るのは、作ったオブジェクトだけ

親の ref.current に何が入るかを、jsdom で調べました。

text
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行目がエラーになりました。

tsx

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';
  };
text
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 は、ファイルの先頭で定義しています。

tsx
// 依存配列が [] … 最初の描画のときだけハンドルを作る
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つだけ作って渡しています。

text
(操作:「に」と入力 →「にゃ」と入力 → 親だけを描き直す)
--- 依存配列が [] ---
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 では書けない操作だけにするよう勧めています。

​Posted by 86