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

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

React Ref の基本

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属性を使っている。

参考資料

React のクラスコンポーネントの bind は何を解決しているのか

クラスコンポーネントでイベントハンドラを設定する際に必要になる、constructorメソッドでのバインド。
そもそもこれはなぜ必要なのか。どのような動作をすることで、どのような問題を解決しているのか。
Hooks が登場したことで今後はクラスコンポーネントを書く機会は減っていくと思うが、これは React 固有の話ではなく JavaScript の言語仕様の話なので、理解しておくに越したことはない。

React のコードは React のv16.10.2で、それ以外のコードは Node.js のv12.12.0で動作確認している。

問題のおさらい

bindしないと何が起こるのか。

以下は、ボタンを押すたびにオンオフが切り替わるコンポーネント。

import React from 'react';

class App extends React.Component {
  constructor() {
    super();
    this.state = {isOn: true};
    this.toggle = this.toggle.bind(this); // この行をコメントアウトすると...?
  }

  toggle() {
    this.setState(state => ({
      isOn: !state.isOn,
    }));
  }

  render() {
    const {state, toggle} = this;

    return (
      <>
        <h1>{state.isOn ? 'ON' : 'OFF'}</h1>
        <button type="button" onClick={toggle}>
          click me
        </button>
      </>
    );
  }
}

export default App;

constructor内のthis.toggle = this.toggle.bind(this);をコメントアウトした状態でボタンを押すと、以下のエラーが出る。

Cannot read property 'setState' of undefined at toggle

toggle内のthisundefinedになってしまい、undefined.setStateにアクセスしたことでエラーになっている。

つまり、イベントハンドラに設定したメソッドのなかで記述したthisが、意図したものとは違うものを指してしまっている。bindを使うことでそれを回避している。

この現象を理解するために、まずクラスについて見ていく。
次に、thisを見失ってしまう問題と、それを解決するbindやアロー関数について説明する。

クラスにおける this と プロトタイプチェーン

クラス定義のなかで書かれたthisは、そのクラスのインスタンスを指す。
なのでthis.xと書けば、そのインスタンスが持っているプロパティxにアクセスできる。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
  }

  sum(arg) {
    return this.x.value + arg;
  }
}

const instanceFoo = new MyClass();
console.log(instanceFoo.x); // { value: 1 }
console.log(instanceFoo.sum(3)); // 4

constructorで定義したプロパティはインスタンスのプロパティである。
一方、メソッドについては通常、プロトタイプメソッドとして定義する。MyClassの場合はxはインスタンス毎に保持し、sumはプロトタイプに格納され、全てのインスタンスがそれを参照する。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
  }

  sum(arg) {
    return this.x.value + arg;
  }
}

const instanceFoo = new MyClass();
const instanceBar = new MyClass();

console.log(instanceFoo.x !== instanceBar.x); // true
console.log(instanceFoo.sum === instanceBar.sum); // true

console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x' ]
console.log(Object.getOwnPropertyNames(MyClass.prototype)); // [ 'constructor', 'sum' ]

このことはつまり、instanceFoo.sumを呼び出したときに、プロトタイプチェーンが発生していることを意味する。
そのため、constructorthis.sumを定義することでオーバーライドすることが可能である。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
    this.sum = () => 'override';
  }

  sum(arg) {
    return this.x.value + arg;
  }
}

const instanceFoo = new MyClass();
console.log(instanceFoo.sum(3)); // "override"

console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x', 'sum' ]

instanceFoo自身がsumを持っているためそれが実行され、MyClass.prototype.sumは実行されない。

関数を入れ子にすると this を見失ってしまう問題とその解決法

JavaScript のthisの闇の深さはよく知られているが、この記事を理解する上で重要なのは「関数を入れ子にするとthisの値が変わってしまう」ということである。

// 入れ子の this が何を指すかは実行環境による
// 以下は Node.js v12.12.0 の場合

global.x = 9;

const obj = {
  x: 1,
  func() {
    const nestFunc = function() {
      console.log(this.x);
    };

    console.log(this.x); // 1
    nestFunc(); // 9
  },
};

obj.func();

thisが何に変わってしまうかは実行環境によって異なるのだが、重要なのは、thisが変わってしまうということ。これによって様々な問題が発生しうることは容易に想像がつく。

この問題を解決するための代表的な手段として、bindとアロー関数がある。

bindFunction.prototypeのメソッドで、そのため全ての関数が使うことが出来る。

console.log(Function.prototype.hasOwnProperty('bind')); // true

関数を返す関数であり、第一引数に渡した値でthisを固定した関数を、返してくれる。

function showThis() {
  console.log(this);
}

showThis.bind(1)(); // [Number: 1]
showThis.bind({value: 5})(); // { value: 5 }

先程の入れ子のケースでは、入れ子になっていない(thisが本来の値を指している)場所でbind(this)とすることで、thisの値が変わってしまうのを防ぐことが出来る。

global.x = 9;

const obj = {
  x: 1,
  func() {
    const nestFunc = function() {
      console.log(this.x);
    };
    const bindedFunc = nestFunc.bind(this); // bind(this) の this は入れ子になっていないので obj を指す

    console.log(this.x); // 1
    nestFunc(); // 9
    bindedFunc(); // 1

    const nestObj = {
      x: 2,
      nestObjFunc() {
        console.log(this.x);
      },
      bindedFunc,
    };
    nestObj.nestObjFunc(); // 2
    nestObj.bindedFunc(); // 1
  },
};

obj.func();

アロー関数を使うことでも、thisを固定することが出来る。アロー関数のなかのthisは、定義したときの場所におけるthisで固定され、変化しない。

global.x = 9;

const obj = {
  x: 1,
  func() {
    const nestFunc = () => {
      console.log(this.x);
    };

    console.log(this.x); // 1
    nestFunc(); // 1

    const nestObj = {
      x: 2,
      nestObjFunc() {
        console.log(this.x);
      },
      nestFunc,
    };
    nestObj.nestObjFunc(); // 2
    nestObj.nestFunc(); // 1
  },
};

obj.func();

クラス定義での this の取り扱い

ここまでの内容を踏まえて、クラスにおけるthisの問題を見ていく。

既述の通りクラス定義のなかのthisはインスタンスを指すが、関数を入れ子にするとやはり、thisの値は変わってしまう。
先程の例ではグローバルオブジェクトに変化していたが、クラス定義ではundefinedに変化する。

class MyClass {
  constructor() {
    this.x = {
      value: 1,
    };
  }

  methodA(callback) {
    console.log(this);
    console.log(this instanceof MyClass);
    callback();
  }

  methodB() {
    console.log(this);
  }
}

const instanceFoo = new MyClass();

// MyClass { x: { value: 1 } }
// true
// undefined
instanceFoo.methodA(instanceFoo.methodB);

イベントハンドラに設定したメソッドのなかでthisundefinedになってしまうクラスコンポーネントの問題も、同じ原因で発生している。

簡易な疑似コンポーネントのようなコードでこの問題を表現すると、以下のようになる。

class ButtonClass {
  setState() {
    console.log('called setState');
  }

  click(callback) {
    // click イベントに共通の処理をここで行う
    // それ以外に、コールバックで指定された任意の処理を行う
    callback();
  }

  toggle() {
    console.log(this);
    try {
      this.setState();
    } catch (e) {
      console.log(e.message);
    }
  }

  render() {
    return {
      onClick: () => {
        this.click(this.toggle);
      },
    };
  }
}

const buttonInstance = new ButtonClass();
const button = buttonInstance.render();

// undefined
// "Cannot read property 'setState' of undefined"
button.onClick();

renderでオブジェクトを返すのだが、そのときにonClickメソッドに対してクリックイベントと、コールバックで実行したいメソッド(this.toggle)を設定している。
そのため、renderによって生成されたbuttononClickを実行すると、toggleメソッドが実行される。
だが、clickのなかでtoggleをコールバックで実行する(clicktoggleもアロー関数ではない)、という入れ子構造になってしまっているため、thisundefinedになってしまい、setStateへのアクセスに失敗してしまう。

そこで、bindとオーバーライドの出番となる。

constructor直下のthisはインスタンスを指しているので、そこでbind(this)を行えば、バインドされた関数のなかのthisはインスタンス変数を指すことになる。

  constructor() {
    function showThis() {
      console.log(this);
    }
    showThis(); // undefined

    console.log(this); // ButtonClass {}

    const bindedFunc = showThis.bind(this);
    bindedFunc(); // ButtonClass {}
  }

そして、バインドした関数でプロトタイプメソッドをオーバーライドしてしまえば、そのメソッドを使うときは必ずthisがインスタンス変数を指すようになる。

class ButtonClass {
  constructor() {
    const bindedFunc = this.toggle.bind(this);
    this.toggle = bindedFunc;
  }

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

  click(callback) {
    // click イベントに共通の処理をここで行う
    // それ以外に、コールバックで指定された任意の処理を行う
    callback();
  }

  toggle() {
    console.log(this);
    try {
      this.setState();
    } catch (e) {
      console.log(e.message);
    }
  }

  render() {
    return {
      onClick: () => {
        this.click(this.toggle);
      },
    };
  }
}

const buttonInstance = new ButtonClass();
const button = buttonInstance.render();

// ButtonClass { toggle: [Function: bound toggle] }
// "called setState"
button.onClick();

バインドされた関数によるオーバーライド、を使わずとも、コールバック関数をアロー関数で渡すことでも解決できる。
ButtonClassの場合はrenderメソッドを以下のように書き換えればよい。
既に述べたように、アロー関数のなかのthisはその場所におけるthisで固定される。

  render() {
    return {
      onClick: () => {
        this.click(() => {
          this.toggle();
        });
      },
    };
  }

しかし実際に React のコンポーネントでこの手法を使うと、パフォーマンスに影響を与える可能性がある。
例えば冒頭のAppコンポーネントのrenderを以下のようにすれば、thisを見失う問題は解決し正しく動くが、Appがレンダリングされる度にコールバック関数が新しく作られてしまう。
この例では問題にならないが、このコールバック関数が子コンポーネントのpropsとして渡されている場合、不要な処理が行われてしまう可能性がある。

  render() {
    const {state} = this;

    return (
      <>
        <h1>{state.isOn ? 'ON' : 'OFF'}</h1>
        <button
          type="button"
          onClick={() => {
            this.toggle();
          }}
        >
          click me
        </button>
      </>
    );
  }

パブリッククラスフィールド

React の公式ドキュメントでは、アロー関数とbindによる解決の他に、パブリッククラスフィールドを用いた方法も紹介している。
イベント処理 – React

これは、現在策定中の新しい構文を利用したもの。
2019年11月現在でstage3なので、まだ正式に採用されているわけではない。
tc39/proposal-class-fields: Orthogonally-informed combination of public and private fields proposals

この構文を使うと、constructorを使わずにインスタンスのプロパティを定義できる。

class MyClass {
  x = {
    value: 1,
  };
}

const instanceFoo = new MyClass();
console.log(instanceFoo.x); // { value: 1 }
console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x' ]

関数も定義できる。その場合は当然、インスタンスのメソッドになる。

class MyClass {
  x = {
    value: 1,
  };

  sum = function(arg) {
    return this.x.value + arg;
  };
}

const instanceFoo = new MyClass();

console.log(instanceFoo.sum(3)); // 4

console.log(Object.getOwnPropertyNames(instanceFoo)); // [ 'x', 'sum' ]
console.log(Object.getOwnPropertyNames(MyClass.prototype)); // [ 'constructor' ]

アロー関数を割り当てることも出来る。何度も繰り返しているように、アロー関数でthisを使った場合は定義時にその中身が決まるため、入れ子になっても値は変わらない。

class MyClass {
  x = {
    value: 1,
  };

  methodA(callback) {
    console.log(this);
    console.log(this instanceof MyClass);
    callback();
  }

  methodB = () => {
    console.log(this);
  };
}

const instanceFoo = new MyClass();

// MyClass { x: { value: 1 }, methodB: [Function: methodB] }
// true
// MyClass { x: { value: 1 }, methodB: [Function: methodB] }
instanceFoo.methodA(instanceFoo.methodB);

以下はこの構文を使ったクラスコンポーネント。
toggleをアロー関数で定義している。

import React from 'react';

class App extends React.Component {
  constructor() {
    super();
    this.state = {isOn: true};
  }

  toggle = () => {
    this.setState(state => ({
      isOn: !state.isOn,
    }));
  };

  render() {
    const {state, toggle} = this;

    return (
      <>
        <h1>{state.isOn ? 'ON' : 'OFF'}</h1>
        <button type="button" onClick={toggle}>
          click me
        </button>
      </>
    );
  }
}

export default App;

まだ策定中の構文のためトランスパイルしないと使えないはずなので、注意。

babeljs.io