しばしば、いくつかのコンポーネントが同一の変化するデータを反映する必要がある場合があります。そんなときは最も近い共通の祖先コンポーネントへ共有されている state をリフトアップすることを推奨します。これを、実際にはどのように行うかを見てみましょう。
この章では、与えられた温度で水が沸騰するかどうかを計算する温度計算ソフトを作成します。
BoilingVerdict
というコンポーネントから始めましょう。これは温度を celsius
という props として受け取り、水が沸騰するのに十分な温度かどうかを表示します。
function BoilingVerdict(props) { if (props.celsius >= 100) { return <p>The water would boil.</p>; } return <p>The water would not boil.</p>; }
次に Calculator
と呼ばれるコンポーネントを作成します。温度を入力するための <input>
要素をレンダーし、入力された値を this.state.temperature
に保持します。
加えて、現在の入力値を判定する BoilingVerdict
もレンダーします。
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>Enter temperature in Celsius:</legend> <input value={temperature} onChange={this.handleChange} /> <BoilingVerdict celsius={parseFloat(temperature)} /> </fieldset> ); } }
2 つ目の入力を追加する
新しい要件は、摂氏の入力に加えて、華氏の入力もできるようにして、それらを同期させておくことです。
Calculator
から TemperatureInput
コンポーネントを抽出するところから始めましょう。props として、"c"
もしくは "f"
の値をとる scale
を新しく追加します:
const scaleNames = { c: 'Celsius', f: 'Fahrenheit' }; class TemperatureInput 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; const scale = this.props.scale; return ( <fieldset> <legend>Enter temperature in {scaleNames[scale]}:</legend> <input value={temperature} onChange={this.handleChange} /> </fieldset> ); } }
これで Calculator
を 2 つの別個の温度入力フィールドをレンダーするように変更することができます:
class Calculator extends React.Component { render() { return ( <div> <TemperatureInput scale="c" /> <TemperatureInput scale="f" /> </div> ); } }
2 つの入力フィールドが用意できました。しかし、片方に温度を入力しても、もう片方は更新されません。これは要件を満たしていません: 2 つの入力フィールドを同期させたいのです。
Calculator
から BoilingVerdict
を表示することもできません。Calculator
は TemperatureInput
の中に隠されている現在の温度を知らないのです。
変換関数の作成
まず、摂氏から華氏に変換するものとその反対のものと、2 つの関数を書きます。
function toCelsius(fahrenheit) { return (fahrenheit - 32) * 5 / 9; } function toFahrenheit(celsius) { return (celsius * 9 / 5) + 32; }
これら 2 つの関数は数字を変換します。次に文字列で表現された temperature
と変換関数を引数に取り文字列を返す、別の関数を作成します。この関数を一方の入力の値をもう一方の入力に基づいて計算するのに使用します。
常に値が小数第 3 位までで四捨五入されるようにし、無効な temperature
には空の文字列を返します。
function tryConvert(temperature, convert) { const input = parseFloat(temperature); if (Number.isNaN(input)) { return ''; } const output = convert(input); const rounded = Math.round(output * 1000) / 1000; return rounded.toString(); }
例えば、tryConvert('abc', toCelsius)
は空の文字列を返し、tryConvert('10.22', toFahrenheit)
は '50.396'
を返します。
state のリフトアップ
現時点では、両方の TemperatureInput
コンポーネントは独立してローカルの state を保持しています:
class TemperatureInput 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; // ...
しかし、2 つの入力フィールドはお互いに同期されていて欲しいのです。摂氏の入力フィールドを更新したら、華氏の入力フィールドも華氏に変換された温度で反映されて欲しいですし、逆も同じです。
React での state の共有は、state を、それを必要とするコンポーネントすべての直近の共通祖先コンポーネントに移動することによって実現します。これを “state のリフトアップ (lifting state up)” と呼びます。TemperatureInput
からローカルの state を削除して Calculator
に移動しましょう。
Calculator
が共有の state を保持すれば、それが両方の入力における現在の温度の “信頼できる情報源 (source of truth)” となります。それによって、両方に対して相互に一貫性のある値を持たせることができるようになります。両方の TemperatureInput
コンポーネントの props は同じ親コンポーネント Calculator
から与えられるので、2 つの入力は常に同期されているようになります。
それでは、どのように動作するのかひとつずつ見ていきましょう。
まず、TemperatureInput
コンポーネントの this.state.temperature
を this.props.temperature
に置き換えます。とりあえず、this.props.temperature
は既にあるものだとしておきましょう。後でこれは Calculator
から渡すようにします:
render() { // Before: const temperature = this.state.temperature; const temperature = this.props.temperature; // ...
props が読み取り専用であることは周知の通りです。temperature
がローカルの state に格納されている間は、TemperatureInput
は this.setState()
を呼び出すだけでそれを変更することができました。しかし今や、temperature
は親コンポーネントから与えられる props の一部ですから、TemperatureInput
はそれを制御できません。
通常 React では、コンポーネントを “制御された (controlled)” ものとすることでこの問題を解決します。DOM である <input>
が value
と onChange
プロパティの両方を受け取るように、カスタムコンポーネントの TemperatureInput
は temperature
と onTemperatureChange
の両方を親コンポーネントの Calculator
から受け取ることができます。
ここで、TemperatureInput
が自身の温度を更新したい場合、this.props.onTemperatureChange
を呼び出します:
handleChange(e) { // Before: this.setState({temperature: e.target.value}); this.props.onTemperatureChange(e.target.value); // ...
補足: カスタムコンポーネントの temperature や onTemperatureChange といった props の名前に特別な意味があるわけではありません。慣習に則り value や onChange など、他の任意の名前を使うこともできます。
onTemperatureChange
プロパティは親コンポーネント Calculator
から temperature
プロパティと共に渡されます。親コンポーネントは入力の変化に応じて自身のローカル state を更新し、結果的に両方の入力フォームは新しい値で再レンダーされます。Calculator
をどう実装するかはこの後すぐに見ていきましょう。
Calculator
の変更点を見ていく前に、TemperatureInput
コンポーネントで行った変更をおさらいしましょう。ローカルの state を削除し、this.state.temperature
の代わりに this.props.temperature
を読み取るようにしました。また、変更を加えたい場合は this.setState()
を呼び出す代わりに Calculator
から与えられる this.props.onTemperatureChange()
を呼び出すことにしました:
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>Enter temperature in {scaleNames[scale]}:</legend> <input value={temperature} onChange={this.handleChange} /> </fieldset> ); } }
では Calculator
コンポーネントの番です。
現時点での入力の temperature
と scale
を、このコンポーネントのローカルな state に保存することにします。これは入力コンポーネントから “リフトアップ” したものであり、両方にとっての “信頼出来る情報源” として振る舞うことになります。これは、両方の入力コンポーネントをレンダーするために必要となる最小のデータの形です。
例えば、摂氏側の入力に 37 と打ち込こむと、Calculator
コンポーネントの state は以下のようになります:
{ temperature: '37', scale: 'c' }
その後に華氏の入力フィールドを 212 に変更すると、Calculator
の state は以下のようになります:
{ temperature: '212', scale: 'f' }
両方の入力を保存することもできましたが、それは不必要だと分かります。最後に変更された値とそれが示す単位を保存すれば十分なのです。現時点での temperature
と scale
の 2 つさえあれば、もう一方の値は推測することができます。
同じ state から値が算出されるので、2 つの入力コンポーネントは常に同期します。
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> ); } }
これで、どちらの入力コンポーネントを編集したかに関わらず、Calculator
の this.state.temperature
と this.state.scale
が更新されます。片方の入力コンポーネントはあらゆるユーザからの入力が保持されるよう値をそのまま受け取り、もう片方の入力コンポーネントの値はそれに基づいて常に再計算されます。
入力値を変更した際に何が起こるのかをおさらいしましょう:
- React は DOM の
<input>
でonChange
として指定された関数を呼び出します。この章の場合、TemperatureInput
のhandleChange
メソッドが呼び出される関数になります。 TemperatureInput
のhandleChange
メソッドはthis.props.onTemperatureChange()
に新しい値を与えて呼び出します。onTemperatureChange
を含む props は親コンポーネントであるCalculator
から与えられます。- 前回のレンダー時に、
Calculator
は摂氏のTemperatureInput
のonTemperatureChange
には自身のhandleCelsiusChange
メソッドを指定し、華氏のTemperatureInput
のonTemperatureChange
には自身のhandleFahrenheitChange
を指定していたのでした。そのため、どちらの入力フィールドを編集したかによって、2 つのCalculator
メソッドのどちらが呼び出されるかが決まります。 - これらのメソッド内では、
Calculator
コンポーネントが新しい入力値と更新した方の入力値の単位をthis.setState()
に与えて呼び出して、React にCalculator
コンポーネント自身を再レンダーさせます。 - React は
Calculator
コンポーネントのrender
メソッドを呼び出して、UI がどのような見た目になるべきかを学びます。両方の入力コンポーネントの値が、現在の温度とアクティブな単位に基づいて再計算されます。温度の変換処理はここで行われます。 - React は
Calculator
により与えられた新しい props で各TemperatureInput
のrender
メソッドを呼び出します。React はそれらの UI がどのような見た目になるかを学びます。 - React は props として摂氏温度を与えて、
BoilingVerdict
コンポーネントのrender
メソッドを呼び出します。 - React DOM は沸騰したかどうかの判定結果と入力コンポーネントの値によって、DOM を更新します。変更された入力コンポーネントは現在の値によって、もう一方の入力コンポーネントは変換された温度によって更新されます。
全ての更新は同じ手順で実行されるので、2 つの入力コンポーネントは常に同期を保つことができます。
この章で学んだこと
React アプリケーションで変化するどのようなデータも単一の “信頼出来る情報源” であるべきです。通常、state はレンダー時にそれを必要とするコンポーネントに最初に追加されます。それから、他のコンポーネントもその state を必要としているなら、直近の共通祖先コンポーネントにその state をリフトアップすることができます。異なるコンポーネント間で state を同期しようとする代わりに、トップダウン型のデータフローの力を借りるべきです。
state のリフトアップは双方向のバインディング (two-way binding) を行う方法より多くの “ボイラープレート” コードを生み出しますが、その効果としてバグを発見して切り出す作業が少なく済むようになります。あらゆる state はいずれかのコンポーネント内に “存在” し、そのコンポーネントのみがその state を変更できるので、バグが潜む範囲は大幅に削減されます。加えて、ユーザ入力を拒否したり変換したりする任意の独自ロジックを実装することもできます。
props もしくは state から派生的に作りだす事のできるデータについては、おそらく state に保持すべきではないでしょう。例えば、今回は celsiusValue
と fahrenheitValue
の両方を保存する代わりに、最後に変更された temperature
と、その値の scale
のみを保存しています。もう一方の入力の値は常に render()
メソッド内で計算することができます。これにより元のユーザ入力の精度を全く損なうことなくもう一方の入力フィールドに丸めを適用したり、もう一方の入力フィールドをクリアしたりできます。
UI で何かおかしな箇所があれば、React Developer Tools を使用して props を調査したり state の更新について責任を持っているコンポーネントに辿り着くまでツリーをさかのぼることができます。これによりバグをその原因まで追いかけることができます。