React公式ドキュメントを読む25 複数コンポーネントでstateを共有する

前回からの続きですが、このページだけ読んでも分かる内容になっています。 今回読むReact公式ドキュメントは「state のリフトアップ」のページです。

複数のコンポーネントで同じstateを共有して制御する場合の管理方法について紹介します。沸騰判定プログラムの実例を参考に、stateの共有をどのように行うのか見てみましょう。

入力された摂氏温度で沸騰しているかどうかを判定するプログラム

以下のコードは、ユーザーによって入力された摂氏温度で、水が沸騰しているかどうかを判定するプログラムです。

// 沸騰しているかどうかを判定するコンポーネント
function BoilingVerdict(props) {
  if (props.celsius >= 100) {
    return <p>沸騰しています!</p>;
  }
  return <p>沸騰していません…</p>;
}

// 摂氏温度入力・管理コンポーネント
class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {temperature: ''};
  }

  handleChange(e) {
    this.setState({temperature: e.target.value});
  }

  render() {
    const temperature = this.state.temperature;
    return (
      <fieldset>
        <legend>摂氏温度を入力してください:</legend>
        <input
          value={temperature}
          onChange={this.handleChange} />
        <BoilingVerdict
          celsius={parseFloat(temperature)} />
      </fieldset>
    );
  }
}

// レンダリング
ReactDOM.render(
  <Calculator />,
  document.getElementById('root')
);

上記のサンプルソースをHTMLファイルにしたものが以下です。

https://programming-world.net/sample/react_doc/lifting-state-up1-1.html

上記の沸騰判定プログラムには、2つのコンポーネントが使われています。摂氏温度を入力するテキストボックスを用意するCalculatorコンポーネントと、水が沸騰しているかどうかを判定するBoilingVerdictコンポーネントです。

Calculatorコンポーネントは、摂氏温度を入力するための<input>要素を描画して、ユーザーが入力した値を this.state.temperature に保持します。

BoilingVerdictコンポーネントは、入力された摂氏温度を celsius という props として受け取り、その値が100以上なら「沸騰しています!」、100未満なら「沸騰していません…」と返します。

摂氏温度と華氏温度のどちらも入力できるようにする

前節の沸騰判定プログラムは、入力された摂氏温度で判定していますが、さらに華氏温度も入力できるようにプログラムを改変してみます。

// 変数の定義
const scaleNames = {
  c: '摂氏温度',
  f: '華氏温度'
};

// 華氏から摂氏への変換関数
function toCelsius(fahrenheit) {
  return (fahrenheit - 32) * 5 / 9;
}

// 摂氏から華氏への変換関数
function toFahrenheit(celsius) {
  return (celsius * 9 / 5) + 32;
}

// 温度を有効な数値に変換する関数
// 引数temperatureは入力された温度値
// 引数convertはtoCelsiusまたはtoFahrenheit
function tryConvert(temperature, convert) {

  // temperature を浮動小数点数にする
  const input = parseFloat(temperature);

  // 無効な temperature には空の文字列を返す
  if (Number.isNaN(input)) {
    return '';
  }

  // 小数第3位までで四捨五入する
  const output = convert(input);
  const rounded = Math.round(output * 1000) / 1000;
  return rounded.toString();
}

// 沸騰しているかどうかを判定するコンポーネント
function BoilingVerdict(props) {
  if (props.celsius >= 100) {
    return <p>沸騰しています!</p>;
  }
  return <p>沸騰していません…</p>;
}

// 【子】TemperatureInputコンポーネント
// 温度入力を受け付ける
class TemperatureInput extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(e) {
    this.props.onTemperatureChange(e.target.value);
  }

  render() {
    const temperature = this.props.temperature;
    const scale = this.props.scale;
    return (
      <fieldset>
        <legend>{scaleNames[scale]}を入力してください:</legend>
        <input value={temperature}
               onChange={this.handleChange} />
      </fieldset>
    );
  }
}

// 【親】Calculatorコンポーネント
// 入力された温度値をstateで管理する
class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
    this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
    this.state = {temperature: '', scale: 'c'};
  }

  handleCelsiusChange(temperature) {
    this.setState({scale: 'c', temperature});
  }

  handleFahrenheitChange(temperature) {
    this.setState({scale: 'f', temperature});
  }

  render() {
    const scale = this.state.scale;
    const temperature = this.state.temperature;
    const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
    const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;

    return (
      <div>
        <TemperatureInput
          scale="c"
          temperature={celsius}
          onTemperatureChange={this.handleCelsiusChange} />
        <TemperatureInput
          scale="f"
          temperature={fahrenheit}
          onTemperatureChange={this.handleFahrenheitChange} />
        <BoilingVerdict
          celsius={parseFloat(celsius)} />
      </div>
    );
  }
}

//レンダリング
ReactDOM.render(
  <Calculator />,
  document.getElementById('root')
);

上記のサンプルソースをHTMLファイルにしたものが以下です。

https://programming-world.net/sample/react_doc/lifting-state-up1-2.html

プログラム改変のポイントは、以下の通りです。

  1. Calculatorコンポーネントから、温度入力部分を TemperatureInput コンポーネントとして抽出しています。
  2. TemperatureInputコンポーネントを2回呼び出して、摂氏と華氏の2つの入力欄を描画しています。
  3. 入力された温度値はテキストとして与えられるので、有効な数値に変換するtryConvert()関数をかましています。
  4. tryConvert()関数の引数には、入力された温度値と、toCelsiusかtoFahrenheitのどちらに変換するかが渡されます。
  5. 摂氏温度が入力された場合には toFahrenheit関数で華氏温度に、華氏温度が入力された場合には toCelsius関数で摂氏温度に変換されて、それぞれの温度入力欄が自動更新されます。

state のリフトアップ

さて、上記のプログラムでは、摂氏温度が入力された場合には華氏温度に、華氏温度が入力された場合には摂氏温度に変換されて、それぞれの温度入力欄が自動更新されます。この挙動は、2つのコンポーネントでstateを共有することで実現しています。

Reactで複数コンポーネント間でstateを共有する場合、stateを必要とする複数コンポーネントの直近の親コンポーネントをstateの共有場所にすることで信頼できる情報源とします。これを「stateのリフトアップ」と呼びます。

上記のプログラムで言うと、親のCalculatorコンポーネントがstateを保持して、それを子のTemperatureInputコンポーネント間で共有します。2つのTemperatureInputコンポーネントは、同じ親コンポーネントからstateを与えられるので2つの入力は常に同期されます。

子のTemperatureInputコンポーネントはstateを更新しない

TemperatureInput コンポーネント内の以下の部分に注目してください。

const temperature = this.props.temperature;

この部分では、変数temperatureに現在の温度値を代入していますが、代入する値が this.state.temperature ではなく this.props.temperature となっています。つまり、TemperatureInputコンポーネント内のローカルstateではなく、Calculator コンポーネントから propsとして渡された値を利用しているということです。

props は読み取り専用です。 TemperatureInputコンポーネントはあくまでも親の Calculator コンポーネントからpropsとして渡された値を利用するだけです。TemperatureInputコンポーネントがinput要素の入力欄に表示される値を更新したい場合には、this.setState() ではなくthis.props.onTemperatureChange を呼び出します。

onTemperatureChangeプロパティは、親のCalculatorコンポーネントから temperatureプロパティと一緒に渡されます。onTemperatureChangeという名前に特別な意味はなく、任意につけられたプロパティの名前に過ぎません。分かりやすいようにそのような名前が付けられています。

親のCalculatorコンポーネントでstateを管理

親のCalculatorコンポーネントでは、現在の temperature と scale を stateで保持します。 親のCalculatorコンポーネントに保持されているstateが、子の TemperatureInputコンポーネントにとっての信頼できる情報源となります。

例えば、「摂氏温度を入力してください:」の入力欄に「37」と打ち込むと、Calculator コンポーネントの state は以下のようになります。

{
  temperature: '37',
  scale: 'c'
}

そのあとで、「華氏温度を入力してください:」の入力欄に「212」と打ち込むと、Calculator コンポーネントの state は以下のように更新されます。

{
  temperature: '212',
  scale: 'f'
}

こんな具合にCalculatorコンポーネントは、this.state.temperature と this.state.scaleを更新して保持します。子となる2つのTemperatureInputコンポーネントは、自分のなかにローカルstateを持つのではなく、Calculatorコンポーネントのstateを信頼できる情報源として利用するので、2つのコンポーネントは常に同期します。

まとめ

異なるコンポーネント間でデータを共有する場合には、 兄弟コンポーネント間で state を同期するのではなく、共通の親コンポーネントの state を利用します。 これを「stateのリフトアップ」と呼びます。

stateのリフトアップ は、トップダウン型のデータフローです。トップダウンのデータフローは、双方向のバインディングと比べるとコードが冗長になるかもしれませんが、考え方がシンプルでバグが入り込みにくくなるメリットがあります。

次回へ続きます。