React で ref ではなく ref.current を操作する理由
🌼 はじめに
2023.02.27 UPDATE)
この記事は一部正確ではない内容がありましたので、タイトルと内容を 「React で ref ではなく ref.current を操作する理由」 に変更しました。 既に読んでくださった方々、申し訳ありませんでした。
1. React で DOM にアクセスする
React の世界で DOM にアクセスするとき ref を使います。
sometimes you might need access to the DOM elements managed by React—for example, to focus a node, scroll to it, or measure its size and position. There is no built-in way to do those things in React, so you will need a ref to the DOM node.
公式サイトで紹介してる簡単なサンプルコードを見てみましょう。
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
useRef
は current
という1つのプロパティだけを持つオブジェクトを返すフックです。
上の例だとinputRef.current
の初期値はnull
で、React が <input>
のノードを生成してinputRef.current
に入れます。これで inputRef.current
から<input>
の DOM にアクセスできるようになります。
ここでもし、こういう疑問抱いたことありませんか?
「なぜinputRef.current
を操作するのか? inputRef
オブジェクトそのものを操作してはいけないのか?」
今回はその疑問を解説していきます。
2. 関数の中の関数
みなさん Javascript で関数の中に関数を持つことができることご存知ですか?この性質を利用したら以下のような実装もできます。
const greet = (name: string) => {
return (greetText: string) => {
// 外側のスコープにある name を参照できる
console.log(`${name}, ${greetText}!`)
}
}
// 関数コール
greet("みんちゃん")("おはよう") // みんちゃん, おはよう!
// 一回だけコールして新しい関数生成
const greetToMin = greet("みんちゃん")
greetToMin("おはよう") // みんちゃん, おはよう!
簡単なサンプルですが、greet
の中に存在する関数が外側のスコープにあるname
変数を参照できることが分かります。
このように、関数が外側のスコープにある変数への参照を保持できる性質をクロージャといいます。みんな大好きなMDNではクロージャをこう説明してます。
クロージャは、 JavaScript でもっとも強力な機能のひとつです。 JavaScript では関数の入れ子が可能であることに加えて、内側の関数が外側の関数内で定義されたすべての変数や関数に対し (外側の関数がアクセスできる、他の変数や関数すべてにも) 自由にアクセスできます。
続いて、すごく大事な説明が出てきます。
また、内側の関数は外側の関数のスコープにアクセスできることから、もし内側の関数が外側の関数よりも長く生存できた場合、外側の関数内で定義された変数や関数は外側の関数よりも長く残る可能性があります。
文字だけ見るとこれが一体どういうことかあまりわからないので、またコードで確認してみましょう。
const greet = (name: string) => {
return (greetText: string) => {
console.log(`${name}, ${greetText}!`)
}
}
let somebody = "みんちゃん"
const greetToSomebody = greet(somebody)
greetToSomebody("おはよう") // みんちゃん, おはよう!
somebody = "James" // James に挨拶するようにアップデート
greetToSomebody("おはよう") // みんちゃん, おはよう!
greetToSomebody
はgreet
関数にsomebody
という変数を渡して作った関数です。このタイミングでsomebody
の値は"みんちゃん"
です。
そしてgreetToSomebody
はname
の値をずっと"みんちゃん"
で覚えるので、後でsomebody
の値を変更しても、その変更がgreetToSomebody
には反映されません。(多分MDNではこの現象を「外側の関数内で定義された変数や関数は外側の関数よりも長く残る」と表現したのではないかと思います。)
もしsomebody
のアップデートを反映したいなら、関数を作り直すしかありません。でも値が更新される度に関数を作り直すことはめんどくさいし、現実的に無理そうです。
3. オブジェクトタイプのメモリアドレス共有
では変数がアップデートされたら関数にそのアップデートを反映させる他の方法を紹介します。
const greet = (name: { current: string }) => {
return (greetText: string) => {
console.log(`${name.current}, ${greetText}!`)
}
}
let somebody = { current: "みんちゃん" }
const greetToSomebody = greet(somebody)
greetToSomebody("おはよう") // みんちゃん, おはよう!
somebody.current = "James"
greetToSomebody("おはよう") // James, おはよう!
今回は関数の作成後にsomebody
の値をアップデートしてもgreetToSomebody
に反映されました。先と違う点は、somebody
が文字列(プリミティブタイプ)の値ではなくオブジェクト(オブジェクトタイプ)であることです。
関数にオブジェクトタイプの変数を渡すと、原本と関数に渡した変数が同じメモリアドレスを共有しているので、後で原本の値を更新したら関数内の変数も更新されます。
Javascript ではプリミティブタイプとオブジェクトタイプの値はメモリに保存されるとき違う挙動をします。
// プリミティブタイプの場合
let num1 = 5
let num2 = num1
num1 = 10
console.log(num2) // 5
// オブジェクトタイプの場合
let fruit = {}
let veggies = fruit
veggies.count = 5
console.log(fruit) // {count: 5}
もしこの挙動の理解が難しい方は昔 Javascript のコピーについて書いた記事を参考にしてみてください!
1つ注意点は、オブジェクトのプロパティを更新するのではなくて新しいオブジェクトを再代入したら新しいメモリアドレスに保存されるということです。
let fruit = {}
let veggies = fruit
veggies = { count: 5 }
console.log(fruit) // {}
このサンプルだとveggies
に{ count: 5 }
オブジェクトを再代入することでfruit
とメモリアドレスが別のものになったので、veggies
の変更がfruit
に反映されなくなったわけです。
React がref
ではなくてref.current
を操作させる理由もここにあります。
最初に見たサンプルコードもう一回見てみましょう。
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
// (1) inputRef オブジェクトを参照してる
inputRef.current.focus();
}
return (
<>
{/* (2) inputRef オブジェクトを参照してる */}
<input ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
useRef
が返したオブジェクトを2箇所で参照していますね。
もしinputRef.current
という特定プロパティではなくてinputRef
オブジェクト自体を再代入して更新したらどうなるでしょう。先ほど学んだ通りメモリアドレスが別々になり、(1)の操作が(2)に反映されなかったり(2)でやってる値の更新が(1)には反映されないはずです。
だから同じメモリアドレスを共有させることで、プロパティのアップデートを ref オブジェクトを参照してるところに全部反映させるためにinputRef.current
を操作させていることだと!思います!!
+) キー名がcurrent
なのはただの React の仕様だと思います。(多分現在の値、最新の値という意味から来たのかな?)
🌷 終わり
この記事がとても分かりやすく説明してくれてるので、たくさん参考にさせていただきました。
Discussion
これは話が逆ですね。「setState が再レンダリングのトリガーになる」が正しくて、それ以外の操作(ref.current の編集など)は react の預かり知るところではないから再レンダリングされないのです
コメントありがとうございます。お陰さまで再レンダリングに関して不正確な内容があったことに気づきましたので、内容とタイトルを変更しました。