React + TypeScript: refでDOMを操作する

React公式サイトのドキュメントが2023年3月16日に改訂されました(「[Introducing react.dev](https://react.dev/blog/2023/03/16/introducing-react-dev)」参照)。本稿は、応用解説の「[Manipulating the DOM with Refs](https://react.dev/learn/manipulating-the-dom-with-refs)」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。

なお、本シリーズ解説の他の記事については「[React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ](https://qiita.com/FumioNonaka/items/d70585bd3c0a6d4edc24)」をご参照ください。

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`に加えられます。すると、[イベントハンドラ](https://qiita.com/FumioNonaka/items/66bb10b77ee99eda6a15)からDOMノードを参照できるようになり、ブラウザ組み込みのノードに定められたAPI([`scrollIntoView()`](https://developer.mozilla.org/docs/Web/API/Element/scrollIntoView))が使えるようになるのです。

```react
import { useRef } from 'react';

export default function App() {
	const myRef = useRef<HTMLDivElement>(null);
	const handler = () => {
		inputRef.current?.focus();
	};
	return (
		<div ref={myRef}>

		</div>
	);
}
```


# 例: テキスト入力フィールドにフォーカスする
つぎのコード例では、ボタンをクリックするとテキスト入力フィールドがフォーカスされます(サンプル001)。

```react:src/App.tsx
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

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-01-zn546d?fontsize=14&hidenavigation=1&theme=dark

実装の手順はつぎのとおりです。

1. `useRef`フックで`ref`(`inputRef`)を宣言してください。
2. `ref`をつぎのようにJSXのノード(`<input />`)に渡します。これでReactは**このDOMノードを`inputRef.current`に加える**のです。
    - `<input ref={inputRef}>`
3. ハンドラ関数(`handleClick`)は、`inputRef.current`からDOMノードが参照できるようになりました。ブラウザAPIの[`focus()`](https://developer.mozilla.org/docs/Web/API/HTMLElement/focus)を呼び出すのはつぎの記述です。
    - `inputRef.current.focus()`
4. あとは、イベントハンドラ(`handleClick`)を`<button>`の`onClick`イベントに与えてください。

DOMの操作は`ref`がもっともよく使われる場合です。けれど、`useRef`フックの使い途はそれにかぎりません。タイマーIDなど、Reactの外の値を収めるためにも用いられます(「[React + TypeScript: refで値を参照する](https://qiita.com/FumioNonaka/items/6960b28c1514049b4419)」参照)。状態と同じように、`ref`はレンダー間で保持されます。状態と異なるのは、値を設定しても再レンダーが起こらないことです。


# 例: 要素にスクロールする
コンポーネントに加えられる`ref`はひとつだけではありません。つぎのコード例は、猫の画像3つのカルーセルです(サンプル002)。それぞれのボタンをクリックすると、対応する画像が中央にスクロールします。呼び出しているのは、中央に表示するDOMノードに備わるブラウザの[`scrollIntoView()`](https://developer.mozilla.org/docs/Web/API/Element/scrollIntoView)メソッドです。

```react:src/App.tsx
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

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-02-z8wddt?fontsize=14&hidenavigation=1&theme=dark

## `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.
```react
<ul>
	{items.map((item) => {
		// 動作しない
		const ref = useRef(null);
		return <li ref={ref} />;
	})}
</ul>
```

[**フックはコンポーネント(またはカスタムフック)のトップレベルでしか呼び出せない**](https://qiita.com/FumioNonaka/items/0961f93d9dcefca7f4dc#:~:text=%E3%83%95%E3%83%83%E3%82%AF%E3%81%AF%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%BE%E3%81%9F%E3%81%AF%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%95%E3%83%83%E3%82%AF%E3%81%AE%E3%83%88%E3%83%83%E3%83%97%E3%83%AC%E3%83%99%E3%83%AB%E3%81%A7%E3%81%97%E3%81%8B%E5%91%BC%E3%81%B3%E5%87%BA%E3%81%9B%E3%81%BE%E3%81%9B%E3%82%93%E3%80%82)からです。条件やループ、入れ子関数、`map()`呼び出しなどから、`useRef`は実行できません。これを避ける方法がふたつ考えられます。

1. ひとつは、親の要素への単一の`ref`を得ることです。そうすれば、[`querySelectorAll`](https://developer.mozilla.org/docs/Web/API/Document/querySelectorAll)のようなDOM操作のメソッドを使って、子ノードが「拾い出せます」。ただし、危ういやり方です。DOMの構造が変わったら、たちまち動かなくなるかもしれません。
2. もうひとつが、**`ref`属性に関数を渡す**、[`ref`コールバック](https://react.dev/reference/react-dom/components/common#ref-callback)と呼ばれる手法です。Reactは、`ref`が設定されるとき、`ref`コールバックの引数にDOMノードを渡して呼び出します。そして、クリアするとき、引数は`null`です。そうすると、このコールバックで配列あるいは`Map`を活用すれば、インデックスやIDから目的のDOMノードが取り出せるでしょう。

つぎのコード例で`ref`(`itemsRef`)にもたせたのはDOMノードではなく[`Map`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Map)です。要素にはキーとなるIDとDOMノードの組みをもたせました。そして、リスト項目(`<li>`要素)の`ref`コールバックが各ノードを`Map`に加えているのです。これで、総数不定の任意のノードにIDを指定してスクロールできるようになりました(サンプル003)。

```react:src/App.tsx
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

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-03-gryqmr?fontsize=14&hidenavigation=1&theme=dark


# 他のコンポーネントのDOMノードを参照する
ブラウザ要素の出力される組み込みコンポーネントに`ref`が加えられると、Reactはその`current`プロパティに対応するDOMノードを設定します。`ref`を定めたのが`<input />`要素であれば、参照されるのはブラウザの実際の`<input />`のDOMノードです。

けれど、**独自のコンポーネント**(たとえば`MyInput`)に`ref`を加えようとすると、デフォルトでは`null`が返されてしまいます。結果としてDOMノードは得られず、つぎのコード例ではボタンをクリックしても`<input />`要素が**フォーカスされません**。

```react:src/App.tsx
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`](https://react.dev/reference/react/forwardRef)で、つぎのように引数のプロパティ(`props`)と`ref`から、`MyInput`の子要素(`<input />`)に参照を渡します。

```react:src/App.tsx
const MyInput = forwardRef<HTMLInputElement>((props, ref) => {
	return <input {...props} ref={ref} />;
});
```

処理の流れはつぎのとおりです。

1. `<MyInput ref={inputRef} />`は、Reactに対応するDOMノードを`inputRef.current`に収めるよう告げます。けれど、それを許すかどうか決めるのは`MyInput`です。デフォルトでは認められません。
2. そこで、`MyInput`コンポーネントは`forwardRef`の使用を宣言しました。こうして、**渡された`inputRef`が`forwardRef`の第2引数として受け取れる**のです。
    - 第1引数(`props`)は、通常どおり親コンポーネントから渡された(`ref`以外の)プロパティを指します。
3. これで、`MyInput`は受け取った`ref`を子の`<input />`に渡せるようになりました。

ボタンクリックで`<input />`がフォーカスされることは、つぎのサンプル004でお確かめください。

#### サンプル004■React + TypeScript: Manipulating the DOM with Refs 04

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-04-kvrvpt?fontsize=14&hidenavigation=1&theme=dark

デザインシステムにおける`ref`の転送は、大きくふたつのパターンに分けて捉えるとよいでしょう。

* 低レベルなコンポーネントのDOMノードを転送することはよく行われます。
    - ボタン、テキスト入力フィールドなどです。
* 高レベルなコンポーネントでは、DOMノードはあまり公開されません。
    - フォーム、リスト、ページセクションなどです。
    - DOM構造に意図せず依存してしまうことを避けます。

## `useImperativeHandle`フックでAPIの一部を公開する
前掲コード例(サンプル004)で`MyInput`は、自身の`<input />`DOM要素そのものを公開しました。それにより、親コンポーネントは要素の`focus()`が呼び出せたのです。けれど、親コンポーネントは他の操作もできてしまいます。たとえば、要素のCSSスタイルを変えてしまううかもしれません。そこまで考慮することは少ないでしょうが、公開する機能を制限することはできます。それが[`useImperativeHandle`](https://react.dev/reference/react/useImperativeHandle)フックです(サンプル005)。

```react:src/App.tsx
// 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

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-05-tfvqwf?fontsize=14&hidenavigation=1&theme=dark


# Reactはいつ`ref`を設定するか
Reactにおける更新は、つぎの[ふたつの段階](https://qiita.com/FumioNonaka/items/e7e285c1bae049706a58)に分けられます。

* **レンダー**中: Reactはコンポーネントの呼び出しにより、何を画面に表示するか決めなければなりません。
* **コミット**中: Reactが、変更されたDOMを最新のレンダリング出力に合わせます。
    - Reactが更新するのは、[レンダリング間で変更のあったDOMノードだけ](https://qiita.com/FumioNonaka/items/e7e285c1bae049706a58#dom%E3%81%AB%E5%A4%89%E6%9B%B4%E3%82%92%E3%82%B3%E3%83%9F%E3%83%83%E3%83%88%E3%81%99%E3%82%8B)です。

一般に、レンダー中に`ref`を参照することは[避けましょう](https://qiita.com/FumioNonaka/items/6960b28c1514049b4419#ref%E3%81%AE%E9%81%A9%E5%88%87%E3%81%AA%E4%BD%BF%E3%81%84%E6%96%B9)。これは、`ref`がDOMを保持する場合も同じです。最初のレンダー中には、DOMノードがまだ作成されていません。そのため、`ref.current`の値は`null`です。また、更新のレンダー中は、DOMノードは変更されていません。`ref.current`の値を読むのは早すぎます。

Reactが`ref.current`を設定するのはコミットのときです。DOMが更新されるまで、Reactは影響を受ける`ref.current`の値は`null`に定めます。そして、DOMが更新されたらすぐに、Reactは値を対応するDOMノードに設定するのです。

多くの場合、**`ref`を参照するのはイベントハンドラでしょう**。`ref`を扱いたいけれど、適切なイベントが見つからないときは、エフェクトの使用も考えられます(「[エフェクト(useEffect)を使わなくてよい場合とは](https://qiita.com/FumioNonaka/items/f6bfe4ca77e590f9e73f)」および「[リアクティブなエフェクト(useEffect)のライフサイクル](https://qiita.com/FumioNonaka/items/a24567403fd7bb20be55)」参照)。

## `flushSync`で状態の更新を一気に同期する
[`flushSync`](https://react.dev/reference/react-dom/flushSync)は、状態の更新を直ちにDOMと同期するためのフックです。Reactでは[状態の更新はレンダリングのキューに加えられます](https://qiita.com/FumioNonaka/items/73cb59fb3cabce4106da)。通常は、これが望ましい動作でしょう。けれど、それでは困る場合が以下のコード例です。

Todoリストに項目を加えたら、[`scrollIntoView`](https://developer.mozilla.org/docs/Web/API/Element/scrollIntoView)メソッドでその(最新の)要素(`<li>`)にスクロールして表示しようとしました。ところが、最後から2番目のリスト項目にスクロールしてしまいます(サンプル006)。

```react:src/App.tsx
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

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-06-73x4wq?fontsize=14&hidenavigation=1&theme=dark

問題は、状態設定関数`setTodos`が直ちにDOMを更新しないことです。そのため、リストの最後の要素にスクロールしようとしても、その項目がまだ加えられていません。ですから、ひとつ前の項目にスクロールしてしまうのです。

解決するためには、ReactがDOMの更新を同期するよう強制しなければなりません。そこで用いるのが`flushSync`フックです。コード例は以下のように書き直します。

1. `flushSync`は`react-dom`から`import`してください。
2. `flushSync`の引数コールバックで**状態設定関数の呼び出しを包みます**。

```react:src/App.tsx
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

https://codesandbox.io/embed/react-typescript-manipulating-the-dom-with-refs-07-xm578m?fontsize=14&hidenavigation=1&theme=dark


# `ref`でDOMを適切に操作するには
`ref`は「非常口」なので、「Reactの外に出る」必要があるときのみお使いください。たとえば、つぎのような場合です。

* フォーカスの管理
* スクロール位置の管理
* Reactが公開していないブラウザAPIの呼び出し

フォーカスやスクロールのようなReactと衝突しない操作であれば、問題はないでしょう。けれど、DOMを外から**書き替える**という場合は、Reactが加える変更と競合するかもしれません。

以下のコードは、あえて競合させた例です。メッセージのテキスト(Hello world)は、ふたつのボタンで表示あるいは非表示されます。第1のボタン([Toggle with setState])による表示・非表示の切り替えは、Reactのいつもの作法です。[状態](https://qiita.com/FumioNonaka/items/0961f93d9dcefca7f4dc)と[条件つきレンダリング](https://qiita.com/FumioNonaka/items/2ba530a906bf97480f80#%E6%9D%A1%E4%BB%B6%E3%81%A4%E3%81%8D%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0conditional-rendering)を使いました。第2のボタン([Remove from the DOM])が用いるのは、DOM APIの[remove()](https://developer.mozilla.org/docs/Web/API/Element/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.

```react:src/App.tsx
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ノードが明示的に公開できます。
* Reactが管理するDOMノードは直接変更しないでください。
* 変更するのはReactが更新する必要のない部分のDOMノードにしましょう。