React のドキュメントを読む④ - 避難ハッチ
ref で値を参照する
コンポーネントに ref を追加する
コンポーネントに情報を「記憶」させたいが、その情報が新しいレンダーをトリガしないようにしたい場合、ref を使うことができる。
コンポーネント内で、useRef フックを呼び出し、唯一の引数として参照したい初期値を渡す。
const ref = useRef(0);
useRef は以下のようなオブジェクトを返す。
{
current: 0 // The value you passed to useRef
}
ref の現在の値には、ref.current
プロパティを通じてアクセスできる。
この値は意図的にミュータブル、つまり読み書きが可能。
ref.current = ref.current + 1;
ref を使うタイミング
通常、ref を使用するのは、コンポーネントが React の外に「踏み出して」、外部 API(多くの場合はコンポーネントの外観に影響を与えないブラウザ API)と通信する必要がある場合。以下は、そのような稀な状況の例。
- タイムアウト ID の保存
- DOM 要素の保存と操作
- JSX を計算するために必要ではないその他のオブジェクトの保存
コンポーネントが値を保存する必要があるがそれがレンダーロジックに影響しないという場合は、ref を選択する。
ref のベストプラクティス
-
ref を避難ハッチ (escape hatch) として扱う
- ref が有用なのは、外部システムやブラウザ API と連携する場合
- アプリケーションのロジックやデータフローの多くが ref に依存しているような場合は、アプローチを見直すことを検討する
-
レンダー中に ref.current を読み書きしない
- レンダー中に情報が必要な場合は、代わりに state を使用する
- React は ref.current が書き換わったタイミングを把握しないため、レンダー中にただそれを読みこむだけでも、コンポーネントの挙動が予測しづらくなってしまう
ref と DOM
ref の最も一般的な使用例は、DOM 要素にアクセスすること。例えば、プログラムで入力にフォーカスを当てたい場合に便利。
<div ref={myRef}>
のようにして JSX の ref 属性に ref を渡すと、React は対応する DOM 要素を myRef.current に入れる。その要素が DOM から削除されると、React は myRef.current を null にセットする。
ref で DOM を操作する
ノードへの ref の取得
参照を得たい DOM ノードに対応する JSX タグの ref 属性に ref を渡すと、React がこの <div> に対応する DOM ノードを作成し、React はこのノードへの参照を myRef.current に入れる。
<div ref={myRef}>
その後、イベントハンドラからこの DOM ノードにアクセスし、ノードに定義されている組み込みのブラウザ API を使用できるようになる。
// You can use any browser APIs, for example:
myRef.current.scrollIntoView();
refコールバックを使ってrefのリストを管理する
こちらを参照。
独自のコンポーネントに ref をおく方法
以下の独自コンポーネント(MyInput)に ref を置こうとすると、デフォルトでは null が返されエラーになる。
import { useRef } from 'react';
function MyInput(props) {
return <input {...props} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
これは、デフォルトでは React は、コンポーネントが他のコンポーネントの DOM ノードにアクセスできないようにしているためである。
ただでさえ ref は控えめに使うべき**避難ハッチ (escape hatch) **。
別のコンポーネントの DOM ノードまで手動で操作できてしまうと、コードがさらに壊れやすくなってしまう。
代わりに、内部の DOM ノードを意図的に公開したいコンポーネントは、そのことを明示的に許可する必要がある。コンポーネントは、自身が受け取った ref を子のいずれかに「転送 (forward)」するよう指定できる。
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
完成コードはこんな感じ。
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
デザインシステムにおいて、ボタン、入力フィールドなどの低レベルなコンポーネントが、内部の DOM ノードに ref を転送することは一般的なパターン。
一方、フォーム、リスト、ページセクションなどの高レベルなコンポーネントは、DOM 構造への偶発的な依存関係を避けるため、通常は DOM ノードを公開しない。
flushSync で state 更新を同期的にフラッシュする
詳細はこちら。
ref を使った DOM 操作のベストプラクティス
ref は避難ハッチ。「React の外に踏み出す」必要がある場合にのみ使用する。
よくある例としては、フォーカスの管理、スクロール位置の管理、または React が公開していないブラウザ API の呼び出しなどが含まれる。
フォーカスやスクロールのような非破壊的なアクションに留めておけば、問題は発生しないはず。ただし、DOM を手動で書き換え(要素の削除など)ようとすると、クラッシュするリスクがあるため慎重になるべし(基本はやめとく)。
エフェクトを使って同期を行う
エフェクトとは何であり、イベントとどう異なるのか
エフェクトは、特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのもの。
チャットでのメッセージ送信は、ユーザが特定のボタンをクリックすることによって直接引き起こされるため、イベントである。
しかし、サーバ接続のセットアップは、コンポーネントが表示される原因となるインタラクションに関係なく行われるべきであるため、エフェクトである。エフェクトは、コミットの最後に、画面が更新された後に実行される。ここが、React コンポーネントを外部システム(ネットワークやサードパーティのライブラリなど)と同期させるのに適したタイミング。
エフェクトはおそらく不要なもの
エフェクトは通常、React のコードから「踏み出して」、何らかの外部システムと同期するために使用されるものだということを肝に銘じる。これには、ブラウザ API、サードパーティのウィジェット、ネットワークなどが含まれる。エフェクトが他の state に基づいて state を調整しているだけの場合、おそらくそのエフェクトは必要ない。
個々のレンダーに別のエフェクトがある
覚えておいたら役立ちそう。こちらを参照。
この知識を定着させたいなら同ページのチャレンジ問題4を解くと良い。
こちらも合わせて参照する。
リアクティブなエフェクトのライフサイクル
1 つのエフェクトは独立した 1 つの同期の処理を表す
コード内の 1つのエフェクトは、1つの独立した同期の処理を表すべき である。
function ChatRoom({ roomId }) {
// logを出力するエフェクト
useEffect(() => {
logVisit(roomId);
}, [roomId]);
// サーバとの接続を確立するエフェクト
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
// ...
}, [roomId]);
// ...
}
React はすべてのリアクティブな値が依存配列に含まれることをチェックする
依存配列には、エフェクトで読み取るすべてのリアクティブな値を含める必要がある(リンタがこれを強制する)。
これにより、無限ループや、エフェクトの再同期が頻発してしまうことがあるが、リンタを抑制してこれらの問題を解決としてはならない。以下を検討する。
-
エフェクトが 1 つの独立した同期の処理を表していることを確認する
- もし、エフェクトが何も同期していない場合、エフェクトは不要かもしれない
- 複数の独立したものを同期している場合は、分割する
-
props や state に反応せずに、最新の値を読み取り、エフェクトを再同期したい場合、エフェクトをリアクティブな部分(エフェクト内に残す)と、非リアクティブな部分(いわゆるエフェクトイベントに抽出する)に分割する
-
オブジェクトや関数を依存配列に含めないようにする
- レンダー中に作成したオブジェクトや関数をエフェクトから読み取ると、これらの値は毎回異なるものになる(これにより、エフェクトが毎回再同期されてしまう)
エフェクトからイベントを分離する
エフェクト内のロジックはリアクティブである
次のようなコードがあるとする。
roomId が変化したら既存のコネクションが破棄され変更後の roomId で接続される。
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
エフェクトから非リアクティブなロジックを分離する
リアクティブなロジックと非リアクティブなロジックを混在させたい場合、少し厄介なことになる。
例えば、ユーザがチャットに接続したときに通知を表示したいとする。
正しい色で通知を表示することができるよう、props から現在のテーマ(ダークまたはライト)を読み取ることにする。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...
このとき theme はエフェクトの依存配列として指定する必要がある。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ All dependencies declared
// ...
しかしここで問題が!
props で渡された theme が変わるたびに useEffect 内が実行され再接続が起きてしまう!
つまり、以下の行は(リアクティブである)エフェクトの中にあるにもかかわらず、リアクティブであってほしくないということになる。
// ...
showNotification('Connected!', theme);
// ...
この非リアクティブなロジックを、周囲にあるリアクティブなエフェクトのコードから分離する方法が必要となる。
useEffectEvent
useEffectEvent
という特別なフックを使うことで、エフェクトからこの非リアクティブなロジックを分離することができる。
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
ここで、onConnected は エフェクトイベント(Effect Event) と呼ばれるもの。
これはエフェクトロジックの一部でありながら、むしろイベントハンドラに近い動作をする。この中のロジックはリアクティブではなく、常に propsとstateの最新の値を「見る」 ことができる。
これで、エフェクトの内部から onConnected エフェクトイベントを呼び出せるようにる。
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ...
これで、theme が変化しても useEffect が再実行されることはなく、roomId が変化した時だけ最新の theme を反映したエフェクトイベントが実行される!
これにより非リアクティブなロジックを、リアクティブなエフェクトのコードから分離することができた👍
ちなみにエフェクトイベントを呼び出せるのはエフェクトの内部だけである。
エフェクトから依存値を取り除く
依存値を削除したければ依存値でないことを示す
エフェクトの依存配列は自分で「選ぶ」たぐいのものではないことに注意する。
エフェクトのコードで使用されるすべてのリアクティブな値は、依存値のリスト内で宣言されなければならない。依存配列は、その周囲にあるコードによって決定される。
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) { // This is a reactive value
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // This Effect reads that reactive value
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ So you must specify that reactive value as a dependency of your Effect
// ...
}
リアクティブな値には props や、コンポーネント内に直接宣言されたすべての変数や関数が含まれる。
roomId はリアクティブな値であるため、依存値のリストから取り除くことはできない。
不要な依存値を取り除く
依存値のどれかが変更されたときにエフェクトを再実行することは、理想的でないケースもある。その際は以下を自問すると良い。
-
コードをイベントハンドラに移動すべきでは?
- そのコードがそもそもエフェクトであるべきかどうかを再度検討する
-
エフェクトが複数の互いに無関係なことを行っていないか?
- 1つのエフェクトに複数の処理を行っていないか確認する
- 1つのエフェクトには1つの処理にとどめる
その他にも前述したuseEffectEvent
を使ってリアクティブと非リアクティブを分離させる方法もある。