【React】useRefの深掘り ~ useStateとの違い、レンダリングの際の挙動等
はじめに
本記事ではuseRef
, useState
について説明した後、両者の違いについて解説します。
useRef とは
レンダリングに必要ない値を参照する際に用いられる React Hook です。
入力
-
initialValue
:ref
のcurrent
の初期値。最初のレンダリング後、この値は無視されます。
返り値
-
current
というプロパティを有するオブジェクトを返します。current
は最初initialValue
で初期化されますが、後に他の値を指定することもできます。このref
オブジェクトを JSX ノードのref
属性として React に渡すと、React はそのcurrent
プロパティに設定します。
ポイント
-
ref.current
を変更しても React は再レンダリングを行いません。ref
は単なる JavaScript オブジェクトであり変更が行われても React はそれを認識しないからです。 - ストリクトモードではコンポーネントは 2 度呼び出されます。これは開発時のみの動作で本番環境には影響しません。各
ref
オブジェクトは 2 回生成されますが、そのうちの 1 つのバージョンは破棄されます。
使い方
ref
による値の参照
前述の通り、useRef
は初期値として与えられた値に設定されたcurrent
プロパティを持つref
オブジェクトを返します。次回以降のレンダリングでcurrent
プロパティを変更して情報を保存し後に読み取ることができます。状態(state)との違いはref
を変更しても再レンダリングが発生しない点です。すなわち、ref
はコンポーネントの視覚的な出力に影響を与えない情報を保存するのに適しています。
すなわち、ref
を用いることにより、以下のことを保証できます。
- 通常の変数はレンダリング毎にリセットされますが、
useRef
では再レンダリング間で情報を保存することができる - 再レンダリングをトリガーする state 変数とは異なり再レンダリングをトリガーしない
- 外部の変数とは異なり、情報はコンポーネントのコピー毎にローカルなもので共有されない
useState
との違いを以下の例から見てみます。startTime
, now
はレンダリングに用いられるため、useState
を用いています。同時にボタンを押してインターバルを停止するためにインターバル ID を保持する必要もあります。インターバル ID はレンダリングには使用されないためref
に保存し手動で更新するのが適切です。
import { useState, useRef } from 'react';
export default const StopWatch = () => {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
const intervalRef = useRef(null);
const handleStart = () => {
setStartTime(Date.now());
setNow(Date.now());
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}
function handleStop = () => {
clearInterval(intervalRef.current);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>Time passed: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>
Start
</button>
<button onClick={handleStop}>
Stop
</button>
</>
);
}
ref
による DOM 操作
React は自動で DOM をレンダリングの結果に合致するように更新するので通常 DOM を操作する必要はありません。しかしノードにフォーカスしたいとき、ノードまでスクロールしたいとき、大きさや位置を計測したいときなど DOM にアクセスする必要のある場面があります。
const Component = () => {
const inputRef = useRef(null);
const handleClick = () => inputRef.current.focus();
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
上の例では React が DOM ノードを作成し画面に表示した後、React はref
のcurrent
プロパティに DOM ノードがセットされます。これにより<input>
の DOM ノードにアクセスし以下のような操作を行うことができます。
const handleClick = () => inputRef.current.focus();
current
はノードが画面から除去された時にnull
に設定されます。
ref
の再生成の防止
React はref
の初期値を次回レンダリング時に無視します。
const Video = () => {
const playerRef = useRef(new VideoPlayer());
}
new VideoPlayer()
の結果は初回レンダリングのとき以外使われていないにもかかわらず、全てのレンダリングでこの関数を実行しています。もしこれが計算のかかるオブジェクトならこの操作は無駄な操作です。これを解決するために、以下のように初期化する方法があります。
const Video = () => {
const playerRef = useRef(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
}
通常ref.current
の値をレンダリング中に読み書きすることは許されていませんが、この場合は結果が常に同じで初期化の時のみif
の内部が実行されるので予測可能なので問題ありません。
他のコンポーネントの DOM ノードにアクセスする時
<input />
のような built-in のコンポーネントにref
を与えると React はcurrent
プロパティを対応する DOM ノードに与えますが、自身でコンポーネントを記述するとnull
が返ってきます。デフォルトで React はコンポーネントに他のコンポーネントの DOM にアクセスすることを子コンポーネントに対しても許さないので、自身でコンポーネントを記述するときは明示的にref
を子コンポーネントに渡す挙動を記述する必要があります。例えば以下のようにforwardRef
を用いて記述します。
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref}>
})
export default const Form() => {
const inputRef = useRef(null);
const handleClick = () => inputRef.current.focus();
return <MyInput ref={inputRef} />;
}
useImperativeHandle
について
上の例ではMyInput
は DOM 要素をアクセス可能にしています。これにより親コンポーネントがfocus
を呼ぶことができるようになりますが、これにより親コンポーネントに想定外の挙動を許してしまう可能性があります。useImperativeHandle
を用いることで、ref
を開放することにより許す機能性を制限することができます。
useImperativeHandle(ref, createHandle, dependencies?)
の入力等は以下のとおりです。
入力
-
ref
:forwardRef
から受け取ったref
-
createHandle
: 入力を受け付けず、アクセス可能にしたいref
操作を返す関数。 - (任意)
dependencies
:createHandle
の中で参照された全ての reactive な値のリスト。Object.is
比較によりそれぞれの依存要素が比較されます。もし再レンダリングがdepenencies
の要素の変更に繋がったとき、あるいはこのdependencies
を省略したとき、createHandle
関数は再実行され、新しく作られたハンドルがref
に割り当てられます。
返り値
useImperativeHandle
はnull
を返します。
使用例
focus
とscrollIntoView
の二つのメソッドのみアクセス可能にしたいとき、例えば以下のようなコードを書きます。
const MyInput = forwardRef(function MyInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => {
return {
focus() {
inputRef.current.focus();
},
scrollIntoView() {
inputRef.current.scrollIntoView();
}
}
}, []);
return <input {...props} ref={inputRef} />;
});
ref
をアタッチするか
いつ React がReact では
- レンダリングにおいて、React はコンポーネントを呼び出し何がスクリーンにあるべきかを特定します。
- コミットにおいて、React は差分を DOM に反映します。
前述したように、レンダリング中ref
にアクセスするべきではありません。最初のレンダリング中、DOM ノードはまだ生成されておらずref.current
はnull
です。また更新のためのレンダリング中も DOM はまだ更新されておらず、読み出すにはまだ早い状態です。React はref.current
の値をコミットの段階でセットします。
useEffect
と共に用いる時
通常、ref
はイベントハンドラからアクセスされます。ただし、イベントが存在しない状態でref
に何か操作を行いたいときはEffect
を用いると解決する可能性があります。例えば以下のコードを見てみます。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
return <video ref={ref} src={src} loop playsInline />;
}
このコードがうまく動かない理由は
- レンダリング中に DOM に操作をしようとしている。React ではレンダリングは純粋な JSX の計算であるべきで DOM の操作のような副作用を有してはならない。
-
VideoPlayer
が最初に呼ばれる際、DOM は存在していない。
ことが原因です。この解決方法として、useEffect
でラップすることでレンダリング計算の外側に動かすことが考えられます。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
return <video ref={ref} src={src} loop playsInline />;
}
DOM の更新をEffect
でラップすることにより、React にスクリーンの更新を先にさせ、その後 Effect が走るようにすることができます。
useState
, useRef
の違い
一部上述しましたが、state
とref
には以下のような違いがあります。
ref | state |
---|---|
useRef(initialValue) は{ current: initialValue} を返す |
useState(initialValue) は現在の状態変数及び setter 関数を返す |
値を変更しても再レンダリングをトリガーしない | 値を変更した際に再レンダリングをトリガーする |
レンダリングプロセスの外でcurrent の値を変更できる |
state の setter 関数を用いて状態変数を変更し再レンダリングされるようにする |
レンダリング中にcurrent を読み書きするべきでない |
いつでもstate を読めるが、各々のレンダリングは状態のスナップショットを有する |
簡潔にまとめるとuseRef
は以下のように実装されているとイメージすることができます。
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
useRef
は常に同じオブジェクトを返す必要があるため、useState
の setter 関数は不要です。
さらにref
とstate
の違いとして、state
は全てのレンダリングに対してスナップショットのように(後述)働きますが、ref
は値を変更するとただちに更新される点があります。
ref.current = 5;
console.log(ref.currente); // 5
この理由はref 自体は通常の JavaScript オブジェクトであるからです。
「スナップショットのように働く」とは
state
の setter 関数により新たな値をセットしたとき、値が書き変わったように見えますが、実際には再レンダリングがトリガーされています。以下のコードを考えます。
const Form = () => {
const [isClicked, setIsClicked] = useState(false);
if (isClicked) {
return <h1>The button is clicked</h1>
}
return (
<button onClick={() => setIsClicked(true)}>
)
}
ボタンを押した際、以下が実行されます。
-
onClick
が実行される -
setIsClicked(true)
によりisClicked
が true になる - React は
isSent
の値に基づきコンポーネントを再レンダリングする
レンダリングとは React が関数であるコンポーネントを呼び出すことを意味しています。props
, イベントハンドラ、ローカル変数は全てそのレンダリングの瞬間の状態から計算されています。React はこのスナップショットに基づき画面を更新しイベントハンドラを接続します。結果、ボタンを押した際 JSX 内のクリックハンドラがトリガーされます。
まとめるとレンダリングの際、以下が実行されます。
- React が関数を再び呼び出す
- 新しい JSX スナップショットを関数が返す
- React がスナップショットに合うように画面を更新する
状態変更が再レンダリングをトリガーするという現象を理解するために以下の例を見てみましょう。
const Counter = () => {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick = {() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
直感的にボタンを押すたびにsetNumber
が 3 回呼び出されるので+3 されそうですが、実際には+1 しかされません。理由は状態を変えても次のレンダリングでしか反映されないからです。全てのsetNumber(number + 1)
においてnumber
は0
なので次のレンダリングでは1
にしかなりません。
また、ここからReact はイベントハンドラの全てのコードを実行するまでつぎの状態更新を行わないことがわかります。例えばイベントハンドラ内でonClick = () => { setColor('blue'); setColor('orange'); setColor('pink'); )}
のように指示しても実際に実行されるのはsetColor('pink')
のみです。
注意点
ref.current
を読み書きしてはならない
レンダリング中にReact はコンポーネント本体が純粋な関数として動くことを想定しています。すなわち
- 入力(props, state, context)が同一なら同一の JSX を返す
- あるコンポーネントが違う順序や異なる引数で呼ばれても他の呼び出しには影響しない
レンダリング中になんらかの情報が必要な時、state
を代わりに用いるべきです。なぜなら React はいつref.current
が変更されるのか知らないからです。レンダリング中にref
の値を読み書きすることはコンポーネントの挙動を予測しづらくします(例外的に、if (!ref.current) ref.current = new Thing()
のようなコードは初回のレンダリングに一度のみref
の値をセットするので許されます)。
ref.current
を指定する時
useEffect の依存配列に以下のようなコードを考えます。
export default function App() {
const ref = useRef();
useEffect(() => {
if (ref.current) {
console.log("hello!");
}
}, [ref.current]);
return (
<>
<h1 ref={ref}>{"hello!"}</h1>
</>
);
}
このコードを実行すると、hello!
とコンソールに 2 回出力されます。これは直感的ではなく想定されていない挙動です。
React の公式ドキュメント(英語版)には以下のような内容が記述されています。
ref
がstable identity(React はレンダリング毎にuseRef
が同じオブジェクトを返すことを保証している)ことに起因します。useRef
の結果は変わらないのでEffect
がref
の変化によってトリガーされることはありません。よって含めても含めなくても問題ありません。同様の性質はuseState
の setter 関数にもみられます。ただし、ref
が親コンポーネントから渡されたときなどはuseEffect
の依存配列に入れる必要がある場合があります。これは親コンポーネントが同じref
を渡しているのか違うref
を条件毎に渡しているのかわからない場合があるからです。
ただこのような問題を見る限り、ref.current
を依存配列に加えないのが正しいというのが個人的な感想です。理由はref
はレンダリングフローの外で変更されるべき値であり、再レンダリングをトリガーしたかったらref
をそもそも用いるべきではないからです。
代わりに以下のように記述することができます。
export default function App() {
const ref = useCallback((node: any) => {
if (node) {
console.log("hello");
}
}, []);
return (
<>
<h1 ref={ref}>{"hello!"}</h1>
</>
);
}
このコードを実行するとhello!
と一回のみコンソールに出力されます。もしref
が変化した時Effect
を走らせたい時、上記の例のようにcallback refを用いると良いかもしれません。
Discussion