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>
      );
    }
  }
    

Посмотреть в CodePen




2.11.1 Добавим ещё один input


Сейчас мы можем задавать скорость только в км/ч. Пусть следующее наше требование - это наличие ещё одного <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>
      );
    }
  }
    

Посмотреть в CodePen


Теперь у нас есть два элемента <input>. Но изменив скорость в одном из них, значение другого не поменяется. Это противоречит нашему требованию, которое гласит: элементы ввода должны быть синхронизированы.

Кроме того, мы теперь не можем использовать компонент SpeedDetector в SpeedRadar, потому что последний ничего не знает о текущей скорости - она спрятана в компоненте SpeedSetter.



2.11.2 Функции конвертации скорости


Для правильной синхронизации, нам понадобятся функции конвертации скорости из км/ч в миль/ч и наоборот:


Код
        
  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) вернёт пустую строку.



2.11.3 Подъём состояния выше по иерархии


Сейчас оба наших компонента 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'};
    }

    MAX_SPEED_IN_CITY_IN_KPH = 60

    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>
      );
    }
  }
    

Посмотреть в CodePen


Мы поменяли название константы 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> всегда остаются синхронизированными.



2.11.4 Извлеченные уроки


В приложении React должен существовать лишь один «источник истины» для любых данных, которые изменяются с течением времени. Обычно состояние сначала добавляется в компонент, которому оно необходимо для отрисовки. Затем, если другие компоненты тоже требуют данные этого состояния, вы можете «поднять» его к их ближайшему общему предку. Не пытайтесь синхронизировать состояние между различными компонентами, вместо этого полагайтесь на нисходящий поток данных.

Подъём состояния приводит к большему объёму «шаблонного» кода, по сравнению с подходами, использующими двойную привязку. Но есть и выйгрыш - на поиск и изоляцию багов уходит меньше времени. Как только какое-нибудь состояние «начало жить» в компоненте и этот компонент единственный, кто может его изменять, область существования багов значительно уменьшается. Плюс ко всему, вы можете реализовать любую кастомную логику, чтобы отклонить или преобразовать пользовательский ввод.

Если что-либо может быть извлечено и из состояния, и из свойств, возможно, это не должно находиться в состоянии. К примеру, вместо хранения в состоянии kphValue и mphValue, мы храним только последнее введённое значение скорости speed и ее единицу измерения unit. Значение другого элемента input всегда может быть вычислено из них в методе render(). Это позволяет нам убрать или применить округление к другому полю без потери точности данных, введенных пользователем.

Если вы видите что-то неправильное в UI, вы можете использовать React Developer Tools, чтобы проинспектировать свойства и подняться вверх по дереву до тех пор, пока не найдете компонент, ответственный за обновление состояния. Это позволит вам выследить источник багов.