30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React Ref の基本

この記事はReact #2 Advent Calendar 2019の5日目の記事です。

React におけるRefには、2つの意味がある。
ひとつはRefオブジェクト。もうひとつは DOM 要素やコンポーネントのref属性。
この2つは関連がないわけではないが、それぞれ別の概念なので、分けて考えたほうが理解しやすい。
まずRefオブジェクトについて扱い、次にref属性について見ていく。

なお、この記事のコードは React のv16.10.2で動作確認している。

Ref オブジェクト

最初にRefオブジェクトの特徴を述べると、コンポーネントがマウントされたときからアンマウントされるときまで存在し続ける、書き換え可能なオブジェクト、である。
この特徴を理解するためにまず、Refではない一般的なオブジェクトの挙動を確認する。

以下のAppコンポーネントは、ボタンを押下するとstateが更新され、その度に再レンダー、つまりAppという関数が再呼び出しされる。
その際、Xとコメントした部分のログは何を表示するだろうか。

import React, {useState} from 'react';

const list = [];

const App = () => {
  const [count, setCount] = useState(0);
  const onClick = () => {
    setCount(c => c + 1);
  };

  const obj = {}; // objは、`Ref`ではない一般的なオブジェクト
  list.push(obj);
  if (list.length >= 2) {
    console.log(list[list.length - 2] === list[list.length - 1]); // X
  }

  return (
    <div>
      <button type="button" onClick={onClick}>
        count up
      </button>
      {count}
    </div>
  );
};

export default App;

答えは、常にfalseを表示する、である。
これは、App関数が実行される度に新しいobjが生成されていることを意味する。
count0だったときのobjcount1だったときのobjは、値は同じ(どちらも空)だが、それぞれ別のオブジェクトである。

Refオブジェクトは、これとは別の振る舞いをする。
Refオブジェクトを生成するにはuseRefを使うので、先程のコードを次のように書き換える。

- import React, {useState} from 'react';
+ import React, {useState, useRef} from 'react';

- const obj = {};
+ const obj = useRef();

すると、Xは常にtrueとなり、objは同一のオブジェクトを指すようになる。
つまり、マウント時にuseRefで生成したRefオブジェクトを、そのまま使い続けている。このRefオブジェクトは、コンポーネントがアンマウントされるまで存在し続ける。

Refオブジェクトは最初からcurrentというプロパティを持っており、useRefに渡した引数がcurrentプロパティの初期値となる。引数を渡さなかった場合はundefinedが初期値になる。
そしてcurrentは、自由に書き換えることが出来る。

最初に述べたように、アンマウントされるまで存在し続けること、自由に書き換えることが出来ること、これがRefオブジェクトの特徴である。
このことは例えば、関数コンポーネントを再レンダーした際に、前回のレンダー時のデータを取得することが可能ということを意味する。

例として、前回のレンダー時のpropsを取得するコンポーネントを書いた。
ChildAppからpropsとしてcountを受け取り、それをNowとして表示する。それとは別に、前回のcountの値をref.currentに保存しておき、それをBeforeとして表示する。

import React, {useState, useRef, useEffect} from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const onClick = () => {
    setCount(c => c + 1);
  };

  return (
    <div>
      <Child count={count} />
      <button type="button" onClick={onClick}>
        count up
      </button>
    </div>
  );
};

const Child = ({count}) => {
  const ref = useRef('--');

  useEffect(() => {
    ref.current = count;
  });

  return (
    <div>
      Before: {ref.current}
      <br />
      Now: {count}
    </div>
  );
};

export default App;

マウント時にChildに渡されるprops.count0。そしてref.currentの初期値は--
その内容に基づいて DOM の更新が行われたあと、useEffectが実行されてref.current0が格納される。

Appのボタンを押すとChildprops.count1に更新されるので、再レンダーが発生する。
このとき、ref.currentには0が入っているので、Beforeとしてそれが表示される。
そして DOM の更新が終わったあと再びuseEffectが実行され、ref.currentに今度は1が格納される。

ボタンを押す度にこれが繰り返される。

Refオブジェクトはpropsstateとは違い、レンダー毎に値が作られるのではないので、レンダーと値が一対一になっていない。そして値が自由に書き換え可能であるため、同じレンダーのなかでも値が変わり得る。
これは、「宣言的に UI を記述する」という React の流儀に反する。便利に使えるケースもあるが多様はせず、React の原則から逸脱せざるを得ないときに、原則から逸脱していることを自覚しながら使うべき。

Refオブジェクトのcurrentプロパティは、その値が更新されても React によってそのことが通知されないので、その点にも注意する。
別の言い方をすると、currentプロパティの更新をトリガーにして自動的に何かが発生することはない。
これも、stateと比較すると分かりやすい。

stateの場合、値が更新されれば React はその内容に応じて再レンダーを行う。
そのため、下記の例では、ボタンを押した1秒後に表示が更新される。

import React, {useState} from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const onClick = () => {
    setTimeout(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  return (
    <div>
      {count}
      <br />
      <button type="button" onClick={onClick}>
        count up
      </button>
    </div>
  );
};

export default App;

だがRefオブジェクトのcurrentプロパティの場合、値が更新されても、それだけでは何も発生しない。
下記の例の場合、ボタンを押した1秒後にref.currentの値がインクリメントされるが、再レンダーは発生しないため、ただ値が変わるだけであり表示にはそれが反映されない。

import React, {useRef} from 'react';

const App = () => {
  const ref = useRef(0);
  const onClick = () => {
    setTimeout(() => {
      ref.current += 1;
    }, 1000);
  };

  return (
    <div>
      {ref.current}
      <br />
      <button type="button" onClick={onClick}>
        count up
      </button>
    </div>
  );
};

export default App;

ref 属性

HTML 要素やクラスコンポーネントにはrefという特別な属性を設定することができ、この属性にはRefオブジェクトか関数を渡すことが出来る。
まずはRefオブジェクトを渡すパターンから見ていく。

HTML 要素のref属性にRefオブジェクトを渡すと、Ref.currentにその HTML 要素が格納される。

import React, {useRef, useEffect} from 'react';

const App = () => {
  const ref = useRef();
  useEffect(() => {
    console.log(ref.current.childNodes); // NodeList(2) [span, span]
    console.log(ref.current.firstChild.textContent); // text1
  });

  return (
    <div ref={ref}>
      <span>text1</span>
      <span>text2</span>
    </div>
  );
};

export default App;

格納されるタイミングは、当該要素がマウントされた後。それまではcurrentにはRefオブジェクトの初期値が入っている。
そして、対象の要素がアンマウントされるとnullが格納される。既述のようにuseRefに引数を渡さなかった場合の初期値はundefinedなので、以下のような挙動になる。useRef(null)とすれば、「要素がマウントされていないときの値はnull」に統一できる。

import React, {useRef, useEffect, useState} from 'react';

const App = () => {
  const [state, setState] = useState(false);
  const ref = useRef();

  useEffect(() => {
    // state が true のときは span 要素
    // state が false のときは null
    // 但し初回レンダー時のみ undefined
    console.log(ref.current);
  });

  const onClick = () => {
    setState(s => !s);
  };

  return (
    <div>
      <button type="button" onClick={onClick}>
        click
      </button>
      {state && <span ref={ref}>text1</span>}
    </div>
  );
};

export default App;

要素が再レンダーされたら、その変更も反映される。マウント時の値がそのまま保持されるわけではない。

import React, {useRef, useEffect, useState} from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const ref = useRef();

  useEffect(() => {
    console.log(ref.current.textContent); // count の値
  });

  const onClick = () => {
    setCount(c => c + 1);
  };

  return (
    <div>
      <button type="button" onClick={onClick}>
        click
      </button>
      <span ref={ref}>{count}</span>
    </div>
  );
};

export default App;

クラスコンポーネントにもref属性を設定することができ、その場合はクラスコンポーネントのインスタンスを得られる。
なので、ref.currentを通じて、そのコンポーネントのプロパティやメソッドにアクセスできる。
下記の例ではAppが、ChildのプロパティxやメソッドmethodAにアクセスしている。

import React, {useRef, useEffect} from 'react';

const App = () => {
  const ref = useRef();

  useEffect(() => {
    console.log(ref.current.x); // 1
  });

  const onClick = () => {
    ref.current.methodA(); // called methodA
  };

  return (
    <div>
      <button type="button" onClick={onClick}>
        click
      </button>
      <Child ref={ref} />
    </div>
  );
};

class Child extends React.Component {
  constructor() {
    super();
    this.x = 1;
    this.methodA = this.methodA.bind(this);
  }

  methodA() {
    console.log('called methodA');
  }

  render() {
    return <span>child text</span>;
  }
}

export default App;

ref.currentの値の移り変わりは HTML 要素のときと同じであり、コンポーネントがアンマウントされたらやはりnullになる。

refは特殊な属性であり、refを渡されたコンポーネントのprops.refでアクセスすることは出来ない。
例えば<Child ref={ref} value={1} />としても、Childpropsにはvalueしか含まれない。

ref属性を設定することが出来るのは HTML 要素とクラスコンポーネントのみであり、関数コンポーネントに設定することは出来ない。

ref 属性に関数を渡す

既に述べたように、Refオブジェクトの内容が更新されても、それが通知されることはない。
そのため、ref属性を設定している要素の状態が変わったタイミングで何かを発火させる、といったことが出来ない。
そのようなことを行いたい場合は、ref属性に関数を渡す。この関数、及びこれを利用した手法を、「コールバック Ref」と呼ぶ。

この関数は、それを渡した要素のマウント時、更新時、アンマウント時に、実行される。
ref属性にRefオブジェクトを渡していたときはRef.currentの書き換えが発生していたが、その代わりにref属性に渡した関数の実行が発生する。
そして、Refオブジェクトの場合はRef.currentに格納されていた値が、関数の引数として渡される。

下記の例では、ボタンを押下する度にspan要素のマウントとアンマウントが交互に行われる。
その度にcallbackRefが実行され、マウント時にはspan要素が、アンマウント時にはnullが、argに渡される。

import React, {useState} from 'react';

const App = () => {
  const [state, setState] = useState(false);

  const onClick = () => {
    setState(s => !s);
  };

  const callbackRef = arg => {
    console.log(arg); // <span>text1</span> と null が交互に表示される
  };

  return (
    <div>
      <button type="button" onClick={onClick}>
        click
      </button>
      {state && <span ref={callbackRef}>text1</span>}
    </div>
  );
};

export default App;

まとめ

既に述べたように、値が変わり得るミュータブルな値を内部に抱えるのは、React の基本的なコンセプトから外れてしまう。
また、ref属性を渡して要素を操作するのも、propsstateによって制御する、という基本的な考え方から逸脱してしまっている。

参考:
numb86-tech.hatenablog.com

そのため、Refの使用は基本的に避けるべき。公式ドキュメントでも、Refに言及する部分では、「こういう使い方が可能だけど、原則としてやるべきではない」という趣旨のことがよく書かれている。

とはいえ、Refが有用なケースもある。
例えば公式ドキュメントでは、アクセシビリティを実現するための手段としてref属性を使っている。

参考資料