👋

React で ref ではなく ref.current を操作する理由

2023/02/26に公開
2

🌼 はじめに

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.

https://beta.reactjs.org/learn/referencing-values-with-refs

公式サイトで紹介してる簡単なサンプルコードを見てみましょう。

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>
    </>
  );
}

useRefcurrentという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("おはよう") // みんちゃん, おはよう! 

greetToSomebodygreet関数にsomebodyという変数を渡して作った関数です。このタイミングでsomebodyの値は"みんちゃん"です。

そしてgreetToSomebodynameの値をずっと"みんちゃん"で覚えるので、後で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 のコピーについて書いた記事を参考にしてみてください!
https://zenn.dev/luvmini511/articles/722cb85067d4e9

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 の仕様だと思います。(多分現在の値、最新の値という意味から来たのかな?)

🌷 終わり

この記事がとても分かりやすく説明してくれてるので、たくさん参考にさせていただきました。
https://tkplaceholder.io/why-do-refs-have-a-key-named-current/

GitHubで編集を提案

Discussion

クロパンダクロパンダ

これは話が逆ですね。「setState が再レンダリングのトリガーになる」が正しくて、それ以外の操作(ref.current の編集など)は react の預かり知るところではないから再レンダリングされないのです

みんちゃんみんちゃん

コメントありがとうございます。お陰さまで再レンダリングに関して不正確な内容があったことに気づきましたので、内容とタイトルを変更しました。