2.11 Подъём состояния выше по иерархии
Очень часто несколько компонентов должны отражать одни и те же данные, которые меняются с течением времени. В таких случаях следует поднимать состояние выше по иерархии: к их ближайшему общему предку. Давайте сделаем это в конкретном примере.
В этом разделе мы создадим полицейский радар скорости, который сообщает о том, превышена ли скорость.
Начнем с компонента под названием SpeedDetector
. Он принимает текущую скорость speed
и максимальную скорость maxSpeed
в км/ч как свойства и выводит сообщение о том, превышена ли скорость:
function SpeedDetector(props) {
if (props.speed >= props.maxSpeed) {
return <div>Скорость превышена!</div>;
}
return <div>Скорость не превышена.</div>;
}
Далее мы создадим компонент SpeedRadar
, представляющий сам радар.
Он будет отрисовывать элемент <input>
, который позволяет нам вводить скорость и хранить её
значение в this.state.speed
.
Пороговое значение скорости, при котором срабатывает радар будем хранить в
константе MAX_SPEED_IN_CITY
- максимально
допустимая скорость движения в населённом пункте.
// максимальная разрешённая скорость в населённом пункте
const MAX_SPEED_IN_CITY = 60
class SpeedRadar extends React.Component {
constructor(props) {
super(props);
this.onChangeSpeed = this.onChangeSpeed.bind(this);
this.state = {speed: null};
}
onChangeSpeed(e) {
this.setState({speed: e.target.value});
}
render() {
const speed = this.state.speed;
return (
<div>
<div>Введите скорость в км/ч:</div>
<input value={speed} onChange={this.onChangeSpeed.bind(this)}/>
<SpeedDetector speed={parseFloat(speed)} maxSpeed={MAX_SPEED_IN_CITY}/>
</div>
);
}
}
Сейчас мы можем задавать скорость только в км/ч. Пусть следующее наше требование - это наличие ещё
одного <input>
, который позволяет вводить скорость в миль/ч. Причём оба этих
<input>
должны быть синхронизированы.
Чтобы достигнуть этой цели из компонента SpeedRadar
следует извлечь компонент, который
будет отвечать за установку скорости. Давайте сделаем это и назовём новый компонент SpeedSetter
.
Также добавим в него свойство unit
, которое может принимать
значения KPH
или MPH
.
const UNIT = {
KPH: 'Км/ч',
MPH: 'Миль/ч'
};
class SpeedSetter extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this)
this.state = {speed: ''}
}
onChange(e) {
this.setState({speed: e.target.value})
}
render() {
let speed = this.state.speed
let unit = this.props.unit
return (
<p>
<span>Введите скорость в "{UNIT[unit]}": </span>
<input value={speed} onChange={this.onChange}/>
</p>
);
}
}
Теперь можно отрефакторить компонент SpeedRadar
, отрисовав в нём
два отдельных установщика скорости.
class SpeedRadar extends React.Component {
render() {
const speed = this.state.speed;
return (
<div>
<SpeedSetter unit='KPH'/>
<SpeedSetter unit='MPH'/>
</div>
);
}
}
Теперь у нас есть два элемента <input>
. Но изменив скорость в одном из них,
значение другого не поменяется. Это противоречит нашему требованию, которое гласит: элементы ввода
должны быть синхронизированы.
Кроме того, мы теперь не можем использовать компонент SpeedDetector
в SpeedRadar
,
потому что последний ничего не знает о текущей скорости - она спрятана в компоненте SpeedSetter
.
Для правильной синхронизации, нам понадобятся функции конвертации скорости из км/ч в миль/ч и наоборот:
function convertToKph(mph) {
return mph * 1.61;
}
function convertToMph(kph) {
return kph / 1.61;
}
Эти функции конвертируют числа. Давайте напишем ещё одну функцию, которая будет принимать
строковое значение скорости и функцию-конвертор, а возвращать - конвертированную скорость опять как строку.
Мы будем использовать её, чтобы вычислить значение одного из <input>
, основываясь на
значении другого.
Также она будет обеспечивать точность до двух знаков после запятой и возвращать пустую строку, если пользователь ввёл невалидное значение скорости. Для улучшения читабельности кода вынесем валидацию скорости в отдельную функцию:
// отдельная функция для валидации скорости
function isValidSpeed(value){
if(value !== null && value !== '' && value !== undefined){
let intValue = parseInt(value);
return !(isNaN(intValue) || !isFinite(intValue));
}
return false
}
function convertSpeed(value, convertor) {
if(isValidSpeed(value)){
const intValue = parseInt(value)
let converted = convertor(intValue);
let rounded = Math.round(converted * 100) / 100
return rounded.toString()
}
return '';
}
К примеру вызов convertSpeed('50', convertToKph)
вернёт значение '31.06'
, а вызов
convertSpeed('Вася', convertToKph)
вернёт пустую строку.
Сейчас оба наших компонента SpeedSetter
хранят собственные значения
скорости в своём локальном состоянии независимо друг от друга:
class SpeedSetter extends React.Component {
constructor(props) {
super(props)
this.onChangeSpeed = this.onChangeSpeed.bind(this)
this.state = {speed: ''}
}
onChangeSpeed(e) {
this.setState({speed: e.target.value})
}
render() {
let speed = this.state.speed
// ...остальной код
Согласно требованиям нам их нужно синхронизировать между собой. Как только будет обновлён установщик «км/ч», установщик «миль/ч» тут же отобразит конвертированное значение, и наоборот.
Чтобы несколько компонентов React могли совместно использовать одно состояние, нужно это состояние поместить в их ближайший общий предок. Это называется «подъём состояния» (в оригинале «lifting state up»).
Следуя этому принципу, давайте удалим локальное состояние из SpeedSetter
и
перенесем его в SpeedRadar
.
Если SpeedRadar
владеет совместно используемым состоянием, то говорят, что он является
единственным «источником истины/достоверной информации» для текущей скорости обоих установщиков.
Он будет поручать установщикам использовать значения скорости, которые согласуются друг с другом. Каждый раз, когда
свойства props
обоих компонентов SpeedSetter
будут приходить с родительского компонента
SpeedRadar
, оба установщика скорости всегда будут синхронизированы.
Давайте пошагово рассмотрим как это работает.
Для начала мы заменим this.state.speed
на this.props.speed
в компонентах SpeedSetter
. Одновременно давайте представим, что
this.props.speed
уже существует, хотя в будущем нам нужно будет
передать его из компонента SpeedRadar
:
render() {
// Ранее: const speed = this.state.speed;
const speed = this.props.speed;
// ...остальной код
Мы знаем, что свойства props
используются только для чтения. Когда скорость speed
находилась в
локальном состоянии установщика SpeedSetter
, он мог вызвать this.setState()
,
чтобы её изменить. Однако сейчас скорость приходит из родительского компонента
в объекте props
, поэтому SpeedSetter
больше не имеет над ней контроля.
С этого момента наш компонент SpeedSetter
становится «контролируемым» компонентом.
По аналогии с тем, как DOM-элемент <input>
принимает свойства value
и onChange
,
компонент SpeedSetter
может принимать свойства speed
и onChangeSpeed
из своего родительского компонента SpeedRadar
.
Теперь, когда SpeedSetter
захочет обновить свою скорость speed
,
он вызовет this.props.onChangeSpeed
:
render() {
onChange(e) {
// Раньше было: this.setState({speed: e.target.value});
this.props.onChangeSpeed(e.target.value)
}
}
Внимание!
В пользовательских компонентах нет никакого особого требования к названиям свойств. В нашем случае этоspeed
и onChangeSpeed
, хотя мы могли бы назвать их
как угодно, например, value
и onChange
, что не противоречит общему соглашению.
Свойства onChangeSpeed
и speed
будут предоставлены вместе родительским
компонентом SpeedRadar
. Он обработает изменение скорости с помощью модификации своего локального состояния.
Это вызовет перерисовку обоих установщиков SpeedSetter
с новыми значениями скорости.
Очень скоро мы увидим новую реализацию SpeedRadar
.
Перед погружением в анализ изменений кода компонента SpeedRadar
, давайте прорезюмируем наши изменения в
компоненте SpeedSetter
. Мы удалили из него локальное состояние, и сейчас вместо чтения значения this.state.speed
,
мы читаем значение this.props.speed
. А когда мы хотим выполнить изменение скорости, вместо вызова this.setState()
,
мы вызываем this.props.onChangeSpeed()
, который будет предоставлен компонентом SpeedRadar
:
class SpeedSetter extends React.Component {
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
}
onChange(e) {
this.props.onChangeSpeed(e.target.value);
}
render() {
const speed = this.props.speed;
const unit = this.props.unit;
return (
<p>
<span>Введите скорость в "{UNIT[unit]}": </span>
<input value={speed} onChange={this.onChange} />
</p>
);
}
}
Теперь давайте перейдем к компоненту SpeedRadar
.
Мы будем хранить текущую введенную скорость speed
и единицу
измерения unit
в его локальном состоянии.
Это состояние мы «подняли» из установщиков скорости. Теперь оно будет
служить для них «единственным источником истины». Это минимальное представление
всех данных, которые нам необходимо знать, чтобы отрисовать оба установщика.
К примеру, если мы вводим значение 40
в установщик «Км/ч», то состояние компонента SpeedRadar
будет:
{
speed: '40',
unit: 'KPH'
}
Если в поле «Миль/ч» мы введём значение 80
, то состояние SpeedRadar
будет:
{
speed: '80',
unit: 'MPH'
}
Мы могли бы хранить значения обоих установщиков, но это излишне.
Достаточно хранить значение недавнего установщика и единицу измерения, которую он представляет.
Далее мы можем определить значение другого установщика, основываясь на текущей
скорости speed
и его единице измерения unit
.
Теперь наши установщики скорости синхронизированы, так как их значения вычислены из одного и того же состояния:
// максимальная разрешённая скорость в населённом пункте в км/ч
const MAX_SPEED_IN_CITY_IN_KPH = 60
class SpeedRadar extends React.Component {
constructor(props){
super(props);
this.onChangeSpeedInKph = this.onChangeSpeedInKph.bind(this);
this.onChangeSpeedInMph = this.onChangeSpeedInMph.bind(this);
this.state = {speed: 0, unit: 'KPH'};
}
onChangeSpeedInKph(speed) {
this.setState({unit: 'KPH', speed});
}
onChangeSpeedInMph(speed) {
this.setState({unit: 'MPH', speed});
}
render() {
const unit = this.state.unit;
const speed = this.state.speed;
const kph = unit === 'MPH' ? сonvertSpeed(speed, convertToKph) : speed;
const mph = unit === 'KPH' ? сonvertSpeed(speed, convertToMph) : speed;
return (
<div>
<SpeedSetter unit="KPH" speed={kph} onChangeSpeed={this.onChangeSpeedInKph}/>
<SpeedSetter unit="MPH" speed={mph} onChangeSpeed={this.onChangeSpeedInMph}/>
<SpeedDetector speed={kph} unit="KPH" maxSpeed={MAX_SPEED_IN_CITY_IN_KPH}/>
</div>
);
}
}
Мы поменяли название константы MAX_SPEED_IN_CITY
на MAX_SPEED_IN_CITY_IN_KPH
,
так как теперь важно знать единицу измерения скорости.
Сейчас не имеет значения в какое поле вы вводите значение: this.state.speed
и this.state.unit
в
компоненте SpeedRadar
будут обновлены. Один из элементов input
получает введённое пользователем
значение, и оно сохраняется в состоянии как есть. На основании него будет вычислено значение другого input
.
Давайте прорезюмируем, что происходит, когда мы редактируем input:
-
React вызывает функцию, указанную в атрибуте
onChange
DOM-элемента<input>
. В нашем случае, это методonChange
в компонентеSpeedSetter
. -
Метод
onChange
компонентаSpeedSetter
вызываетthis.props.onChangeSpeed()
с новым желаемым значением скорости. Его свойства, включаяonChangeSpeed
, были предоставлены родительским компонентом -SpeedRadar
. -
Когда
SpeedRadar
отрисовывался в последний раз, он указал, чтоonChangeSpeed
компонентаSpeedSetter
с единицами «Км/ч» является методомonChangeSpeedInKph
компонентаSpeedRadar
, аonChangeSpeed
компонентаSpeedSetter
с единицами «Миль/ч» соответственно является методомonChangeSpeedInMph
. Таким образом, в зависимости от того, какой<input>
мы отредактировали, будет вызван тот или иной метод компонентаSpeedRadar
. -
Внутри этих методов, компонент
SpeedRadar
запрашивает у React перерисовку, используя вызовthis.setState()
с новым введенным значением скорости и её единицей измерения. -
React вызывает метод
render()
компонентаSpeedRadar
, чтобы понять как должен выглядеть UI. Значения обоих элементов<input>
пересчитываются, на основании текущих значения скорости и единицы измерения. Здесь же выполняется и конвертация скорости. -
React индивидуально вызывает методы
render()
компонентовSpeedSetter
с их новыми свойствами, указанными компонентомSpeedRadar
. Так он узнает, как должен выглядеть их UI. -
React DOM обновляет DOM, чтобы привести в соответствие значения установщиков. Элемент
<input>
, который мы только что отредактировали, принимает своё текущее значение, а второй<input>
обновляется до значения скорости после конвертации.
Каждое обновление проходит по точно такому же алгоритму, так что элементы
<input>
всегда остаются синхронизированными.
В приложении React должен существовать лишь один «источник истины» для любых данных, которые изменяются с течением времени. Обычно состояние сначала добавляется в компонент, которому оно необходимо для отрисовки. Затем, если другие компоненты тоже требуют данные этого состояния, вы можете «поднять» его к их ближайшему общему предку. Не пытайтесь синхронизировать состояние между различными компонентами, вместо этого полагайтесь на нисходящий поток данных.
Подъём состояния приводит к большему объёму «шаблонного» кода, по сравнению с подходами, использующими двойную привязку. Но есть и выйгрыш - на поиск и изоляцию багов уходит меньше времени. Как только какое-нибудь состояние «начало жить» в компоненте и этот компонент единственный, кто может его изменять, область существования багов значительно уменьшается. Плюс ко всему, вы можете реализовать любую кастомную логику, чтобы отклонить или преобразовать пользовательский ввод.
Если что-либо может быть извлечено и из состояния, и из свойств, возможно, это не должно
находиться в состоянии. К примеру, вместо хранения в состоянии kphValue
и mphValue
, мы храним
только последнее введённое значение скорости speed
и ее единицу измерения unit
.
Значение другого элемента input
всегда может быть вычислено из них в методе render()
. Это позволяет
нам убрать или применить округление к другому полю без потери точности данных, введенных пользователем.
Если вы видите что-то неправильное в UI, вы можете использовать , чтобы проинспектировать свойства и подняться вверх по дереву до тех пор, пока не найдете компонент, ответственный за обновление состояния. Это позволит вам выследить источник багов.