React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、応用解説の「Manipulating the DOM with Refs」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。
なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。
ReactがDOMを更新してレンダリング出力に合わせる処理は自動的です。そのため、コンポーネントがDOMを操作しなければならないことはあまりありません。けれど、DOMを参照してReactに操作させたい場合も出てくるでしょう。たとえば、つぎのようなときです。
- ノードにフォーカスしたい。
- ノードをスクロールさせたい。
- ノードのサイズや位置を調べたい。
もっとも、Reactにはそのような組み込みの仕組みはありません。そこで、ref
によりDOMノードを参照するのです。
ref
でノードへの参照を得る
Reactが管理するDOMノードは、つぎのようにして参照します。
-
useRef
フックをimport
しましょう。 - コンポーネント内で
ref
(myRef
)を宣言するために用いるのが、useRef
です。 -
ref
(myRef
)はDOMノードを取得したいJSXタグのref
属性として渡してください。
useRef
フックが返すオブジェクトは、current
というひとつのプロパティしかもちません。つぎのコード例では、myRef.current
の初期値はnull
です。Reactがこの<div>
要素のDOMノードをつくると、ノードの参照がmyRef.current
に加えられます。すると、イベントハンドラからDOMノードを参照できるようになり、ブラウザ組み込みのノードに定められたAPI(scrollIntoView()
)が使えるようになるのです。
import { useRef } from 'react';
export default function App() {
const myRef = useRef<HTMLDivElement>(null);
const handler = () => {
inputRef.current?.focus();
};
return (
<div ref={myRef}>
</div>
);
}
例: テキスト入力フィールドにフォーカスする
つぎのコード例では、ボタンをクリックするとテキスト入力フィールドがフォーカスされます(サンプル001)。
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.focus();
};
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
サンプル001■React + TypeScript: Manipulating the DOM with Refs 01
実装の手順はつぎのとおりです。
-
useRef
フックでref
(inputRef
)を宣言してください。 -
ref
をつぎのようにJSXのノード(<input />
)に渡します。これでReactはこのDOMノードをinputRef.current
に加えるのです。<input ref={inputRef}>
- ハンドラ関数(
handleClick
)は、inputRef.current
からDOMノードが参照できるようになりました。ブラウザAPIのfocus()
を呼び出すのはつぎの記述です。inputRef.current.focus()
- あとは、イベントハンドラ(
handleClick
)を<button>
のonClick
イベントに与えてください。
DOMの操作はref
がもっともよく使われる場合です。けれど、useRef
フックの使い途はそれにかぎりません。タイマーIDなど、Reactの外の値を収めるためにも用いられます(「React + TypeScript: refで値を参照する」参照)。状態と同じように、ref
はレンダー間で保持されます。状態と異なるのは、値を設定しても再レンダーが起こらないことです。
例: 要素にスクロールする
コンポーネントに加えられるref
はひとつだけではありません。つぎのコード例は、猫の画像3つのカルーセルです(サンプル002)。それぞれのボタンをクリックすると、対応する画像が中央にスクロールします。呼び出しているのは、中央に表示するDOMノードに備わるブラウザのscrollIntoView()
メソッドです。
export default function CatFriends() {
const firstCatRef = useRef<HTMLImageElement>(null);
// ...[略]...
const handleScrollToCat = (catRef: RefObject<HTMLImageElement>) => {
catRef.current?.scrollIntoView(scrollIntoViewOptions);
};
return (
<>
<nav>
<button onClick={() => handleScrollToCat(firstCatRef)}>Tom</button>
{/* ...[略]... */}
</nav>
<div>
<ul>
<li>
<img
src="https://placekitten.com/g/200/200"
alt="Tom"
ref={firstCatRef}
/>
</li>
{/* ...[略]... */}
</ul>
</div>
</>
);
}
サンプル002■React + TypeScript: Manipulating the DOM with Refs 02
ref
のリストをref
コールバックの使用により管理する
前掲のコード例(サンプル002)では、ref
の数があらかじめ定まっていました。けれど、リスト内の各項目にref
を与えたい場合もあるでしょう。その数は先に決まっていないかもしれません。けれども、以下のコードはつぎのエラーにより禁じられます。
React Hook "useRef" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function.
<ul>
{items.map((item) => {
// 動作しない
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
フックはコンポーネント(またはカスタムフック)のトップレベルでしか呼び出せないからです。条件やループ、入れ子関数、map()
呼び出しなどから、useRef
は実行できません。これを避ける方法がふたつ考えられます。
- ひとつは、親の要素への単一の
ref
を得ることです。そうすれば、querySelectorAll
のようなDOM操作のメソッドを使って、子ノードが「拾い出せます」。ただし、危ういやり方です。DOMの構造が変わったら、たちまち動かなくなるかもしれません。 - もうひとつが、
ref
属性に関数を渡す、ref
コールバックと呼ばれる手法です。Reactは、ref
が設定されるとき、ref
コールバックの引数にDOMノードを渡して呼び出します。そして、クリアするとき、引数はnull
です。そうすると、このコールバックで配列あるいはMap
を活用すれば、インデックスやIDから目的のDOMノードが取り出せるでしょう。
つぎのコード例でref
(itemsRef
)にもたせたのはDOMノードではなくMap
です。要素にはキーとなるIDとDOMノードの組みをもたせました。そして、リスト項目(<li>
要素)のref
コールバックが各ノードをMap
に加えているのです。これで、総数不定の任意のノードにIDを指定してスクロールできるようになりました(サンプル003)。
const catList = Array.from(new Array(10), (_, index) => ({
id: index,
imageUrl: 'https://placekitten.com/250/200?image=' + index
}));
export default function CatFriends() {
const itemsRef = useRef<Map<number, HTMLLIElement> | null>(null);
const getMap = () => {
if (!itemsRef.current) {
itemsRef.current = new Map();
}
return itemsRef.current;
};
const scrollToId = (itemId: number) => {
const map = getMap();
const node = map.get(itemId);
node?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
};
return (
<>
<div>
<ul>
{catList.map((cat) => (
<li
key={cat.id}
ref={(node) => {
const map = getMap();
if (node) {
map.set(cat.id, node); // Mapに追加
} else {
map.delete(cat.id); // Mapから削除
}
}}
>
<img src={cat.imageUrl} alt={"Cat #" + cat.id} />
</li>
))}
</ul>
</div>
</>
);
}
サンプル003■React + TypeScript: Manipulating the DOM with Refs 03
他のコンポーネントのDOMノードを参照する
ブラウザ要素の出力される組み込みコンポーネントにref
が加えられると、Reactはそのcurrent
プロパティに対応するDOMノードを設定します。ref
を定めたのが<input />
要素であれば、参照されるのはブラウザの実際の<input />
のDOMノードです。
けれど、独自のコンポーネント(たとえばMyInput
)にref
を加えようとすると、デフォルトではnull
が返されてしまいます。結果としてDOMノードは得られず、つぎのコード例ではボタンをクリックしても<input />
要素がフォーカスされません。
const MyInput: FC<Props> = (props) => {
return <input ref={props.ref} />;
};
export default function MyForm() {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.focus();
};
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</>
);
}
コンソールに示されるのは、つぎのエラーです。
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
Reactはデフォルトでは、他のコンポーネントのDOMノードを参照することは許しません。それは、子コンポーネントに対しても同じで、意図された仕様です。ref
はあくまで「非常口」なので、むやみに使うべきではありません。他のコンポーネントのDOMノードが勝手に書き替えられると、コードを堅牢に保てなくなります。
DOMノードを他のコンポーネントに参照させるには、それを明示的に認めなければなりません。コンポーネントは受け取ったref
を子に「転送」(forward)することができるのです。そのために用いるAPIがforwardRef
で、つぎのように引数のプロパティ(props
)とref
から、MyInput
の子要素(<input />
)に参照を渡します。
const MyInput = forwardRef<HTMLInputElement>((props, ref) => {
return <input {...props} ref={ref} />;
});
処理の流れはつぎのとおりです。
-
<MyInput ref={inputRef} />
は、Reactに対応するDOMノードをinputRef.current
に収めるよう告げます。けれど、それを許すかどうか決めるのはMyInput
です。デフォルトでは認められません。 - そこで、
MyInput
コンポーネントはforwardRef
の使用を宣言しました。こうして、渡されたinputRef
がforwardRef
の第2引数として受け取れるのです。- 第1引数(
props
)は、通常どおり親コンポーネントから渡された(ref
以外の)プロパティを指します。
- 第1引数(
- これで、
MyInput
は受け取ったref
を子の<input />
に渡せるようになりました。
ボタンクリックで<input />
がフォーカスされることは、つぎのサンプル004でお確かめください。
サンプル004■React + TypeScript: Manipulating the DOM with Refs 04
デザインシステムにおけるref
の転送は、大きくふたつのパターンに分けて捉えるとよいでしょう。
- 低レベルなコンポーネントのDOMノードを転送することはよく行われます。
- ボタン、テキスト入力フィールドなどです。
- 高レベルなコンポーネントでは、DOMノードはあまり公開されません。
- フォーム、リスト、ページセクションなどです。
- DOM構造に意図せず依存してしまうことを避けます。
useImperativeHandle
フックでAPIの一部を公開する
前掲コード例(サンプル004)でMyInput
は、自身の<input />
DOM要素そのものを公開しました。それにより、親コンポーネントは要素のfocus()
が呼び出せたのです。けれど、親コンポーネントは他の操作もできてしまいます。たとえば、要素のCSSスタイルを変えてしまううかもしれません。そこまで考慮することは少ないでしょうが、公開する機能を制限することはできます。それがuseImperativeHandle
フックです(サンプル005)。
// const MyInput = forwardRef<HTMLInputElement>((props, ref) => {
const MyInput = forwardRef<Handler>((props, ref) => {
const realInputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
// メソッドfocus()のみ公開する
focus() {
realInputRef.current?.focus();
}
}));
// return <input {...props} ref={ref} />;
return <input {...props} ref={realInputRef} />;
});
このコード例では、MyInput
内のrealInputRef
が実際の<input />
のDOMノードを保持します。けれど、Reactに特別なオブジェクトをref
の値として親コンポーネントに渡すよう告げるのがuseImperativeHandle
です。すると、Form
コンポーネントのinputRef.current
は、focus
メソッドしかもちません。ここでのref
「ハンドル」はDOMノードではなく、useImperativeHandle
の呼び出しによってつくられるカスタムオブジェクトだからです。
サンプル005■React + TypeScript: Manipulating the DOM with Refs 05
Reactはいつref
を設定するか
Reactにおける更新は、つぎのふたつの段階に分けられます。
- レンダー中: Reactはコンポーネントの呼び出しにより、何を画面に表示するか決めなければなりません。
-
コミット中: Reactが、変更されたDOMを最新のレンダリング出力に合わせます。
- Reactが更新するのは、レンダリング間で変更のあったDOMノードだけです。
一般に、レンダー中にref
を参照することは避けましょう。これは、ref
がDOMを保持する場合も同じです。最初のレンダー中には、DOMノードがまだ作成されていません。そのため、ref.current
の値はnull
です。また、更新のレンダー中は、DOMノードは変更されていません。ref.current
の値を読むのは早すぎます。
Reactがref.current
を設定するのはコミットのときです。DOMが更新されるまで、Reactは影響を受けるref.current
の値はnull
に定めます。そして、DOMが更新されたらすぐに、Reactは値を対応するDOMノードに設定するのです。
多くの場合、ref
を参照するのはイベントハンドラでしょう。ref
を扱いたいけれど、適切なイベントが見つからないときは、エフェクトの使用も考えられます(「エフェクト(useEffect)を使わなくてよい場合とは」および「リアクティブなエフェクト(useEffect)のライフサイクル」参照)。
flushSync
で状態の更新を一気に同期する
flushSync
は、状態の更新を直ちにDOMと同期するためのフックです。Reactでは状態の更新はレンダリングのキューに加えられます。通常は、これが望ましい動作でしょう。けれど、それでは困る場合が以下のコード例です。
Todoリストに項目を加えたら、scrollIntoView
メソッドでその(最新の)要素(<li>
)にスクロールして表示しようとしました。ところが、最後から2番目のリスト項目にスクロールしてしまいます(サンプル006)。
export default function TodoList() {
const listRef = useRef<HTMLUListElement>(null);
const [todos, setTodos] = useState(initialTodos);
const handleAdd = () => {
const newTodo = { id: nextId++, text };
setTodos([...todos, newTodo]);
(listRef.current?.lastChild as HTMLLIElement).scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
};
}
サンプル006■React + TypeScript: Manipulating the DOM with Refs 06
問題は、状態設定関数setTodos
が直ちにDOMを更新しないことです。そのため、リストの最後の要素にスクロールしようとしても、その項目がまだ加えられていません。ですから、ひとつ前の項目にスクロールしてしまうのです。
解決するためには、ReactがDOMの更新を同期するよう強制しなければなりません。そこで用いるのがflushSync
フックです。コード例は以下のように書き直します。
-
flushSync
はreact-dom
からimport
してください。 -
flushSync
の引数コールバックで状態設定関数の呼び出しを包みます。
import { flushSync } from 'react-dom'; // react-domからimport
export default function TodoList() {
const listRef = useRef<HTMLUListElement>(null);
const [todos, setTodos] = useState(initialTodos);
const handleAdd = () => {
const newTodo = { id: nextId++, text };
flushSync(() => { // 状態設定関数の呼び出しを包む
setTodos([...todos, newTodo]);
});
(listRef.current?.lastChild as HTMLLIElement).scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
};
}
これで、ReactによるDOMの更新は、flushSync
に包まれたコードが実行された直後に同期するようになりました。つまり、最新の項目が加わったDOMに対してスクロールするのです(サンプル007)。
サンプル007■React + TypeScript: Manipulating the DOM with Refs 07
ref
でDOMを適切に操作するには
ref
は「非常口」なので、「Reactの外に出る」必要があるときのみお使いください。たとえば、つぎのような場合です。
- フォーカスの管理
- スクロール位置の管理
- Reactが公開していないブラウザAPIの呼び出し
フォーカスやスクロールのようなReactと衝突しない操作であれば、問題はないでしょう。けれど、DOMを外から書き替えるという場合は、Reactが加える変更と競合するかもしれません。
以下のコードは、あえて競合させた例です。メッセージのテキスト(Hello world)は、ふたつのボタンで表示あるいは非表示されます。第1のボタン([Toggle with setState])による表示・非表示の切り替えは、Reactのいつもの作法です。状態と条件つきレンダリングを使いました。第2のボタン([Remove from the DOM])が用いるのは、DOM APIのremove()です。Reactの制御外から強制的にDOMを削除しています。
[Toggle with setState]だけを操作していれば問題ありません。けれど、表示されたテキストを[Remove from the DOM]で削除してから[Toggle with setState]がクリックされたとき示されるのはつぎのエラーです。
NotFoundError
Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
export default function App() {
const [show, setShow] = useState(true);
const ref = useRef<HTMLParagraphElement>(null);
return (
<div>
<button
onClick={() => {
setShow(!show);
}}
>
Toggle with setState
</button>
<button
onClick={() => {
ref.current?.remove();
}}
>
Remove from the DOM
</button>
{show && <p ref={ref}>Hello world</p>}
</div>
);
}
DOM要素はReactの外から強制的に除かれました。そのため、Reactは状態を正しく管理し続けることができなくなってしまったのです。
Reactが管理するDOMノードは直接変更しないでください。外からの要素の変更や、子の追加あるいは削除をReactが管理するDOMノードに対して行うと、表示との齟齬や前掲コード例のようなクラッシュにつながりかねません。
もっとも、まったくできないのではなく、注意しなければならないということです。Reactが更新する必要のない部分のDOMは、変更しても問題ありません。たとえば、JSXでつねに空の<div>
です。Reactはその子要素のリストに気を配る必要がありません。Reactと競合することなく、外から要素を追加したり、削除できます。
まとめ
この記事では、つぎのような項目についてご説明しました。
-
ref
は汎用的な機能です。ただ、ほとんどの場合、DOM要素の保持に用いられるでしょう。 -
ref
(myRef
)を要素に<div ref={myRef}>
として加えれば、myRef.current
からDOMノードが参照できます。 -
ref
を用いるのは、おもにReactと衝突しない操作です。- フォーカスやスクロール、あるいはDOM要素の座標的な確認など。
- コンポーネントはデフォルトではDOMノードを公開しません。DOMノードを他のコンポーネントに参照させるために用いるのが
forwardRef
です。- 第2引数に受け取った
ref
を渡してDOMノードが明示的に公開できます。
- 第2引数に受け取った
- Reactが管理するDOMノードは直接変更しないでください。
- 変更するのはReactが更新する必要のない部分のDOMノードにしましょう。