LoginSignup
9
4

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

Last updated at Posted at 2023-06-26

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)。

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

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

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

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

例: 要素にスクロールする

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

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

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は実行できません。これを避ける方法がふたつ考えられます。

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

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

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

他のコンポーネントのDOMノードを参照する

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

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

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で、つぎのように引数のプロパティ(props)とrefから、MyInputの子要素(<input />)に参照を渡します。

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の使用を宣言しました。こうして、渡されたinputRefforwardRefの第2引数として受け取れるのです。
    • 第1引数(props)は、通常どおり親コンポーネントから渡された(ref以外の)プロパティを指します。
  3. これで、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)。

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

Reactはいつrefを設定するか

Reactにおける更新は、つぎのふたつの段階に分けられます。

  • レンダー中: Reactはコンポーネントの呼び出しにより、何を画面に表示するか決めなければなりません。
  • コミット中: 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)。

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

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

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

  1. flushSyncreact-domからimportしてください。
  2. flushSyncの引数コールバックで状態設定関数の呼び出しを包みます
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

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.

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ノードにしましょう。
9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4