2.11 Передача состояния вверх по иерархии

Часто несколько компонентов должны отражать одни и те же изменения данных. В таких случаях рекомендуется передавать состояние «вверх» по иерархии их ближайшему общему предку. Давайте посмотрим как это работает на деле.

В этом разделе мы создадим полицейский радар скорости, который сообщает – превышена ли текущая скорость.

Начнем с компонента под названием SpeedDetector. Он принимает текущую скорость speed и максимальную скорость maxSpeed в км/ч как свойства и выводит сообщение, превышена ли скорость:


Код
        
  function SpeedDetector(props) {
    if (props.speed >= props.maxSpeed) {
      return <div>Скорость превышена!</div>;
    }
    return <div>Скорость не превышена.</div>;
  }
    

Далее мы создадим компонент – радар скорости под названием SpeedRadar. Он отрисовывает <input>, который позволяет нам вводить скорость и хранить ее значение в this.state.speed.


Код
        
  class SpeedRadar extends React.Component {
    MAX_SPEED_COUNTRY = 60;

    constructor(props) {
      super(props);
      this.onSpeedChange = this.onSpeedChange.bind(this);
      this.state = {speed: null};
    }

    onSpeedChange(e) {
      this.setState({speed: e.target.value});
    }

    render() {
      const speed = this.state.speed;
      return (
      <div>
        <div>Введите скорость в км/ч:</div>
        <input value={speed} onChange={this.onSpeedChange.bind(this)}/>
        <SpeedDetector speed={parseFloat(speed)} maxSpeed={this.MAX_SPEED_COUNTRY}/>
      </div>
      );
    }
  }
    

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



2.11.1 Передача состояния вверх по иерархии

На данный момент оба компонента SpeedSetter независимо хранят свои значения в локальном состоянии:


Код
        
  class SpeedSetter extends React.Component {
    constructor(props) {
      super(props);
      this.onSpeedChange = this.onSpeedChange.bind(this);
      this.state = {speed: null};
    }

    onSpeedChange(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;
  }
    

Мы знаем, что свойства используются только для чтения. Когда скорость speed была в локальном состоянии, компонент SpeedSetter мог просто вызвать this.setState(), чтобы ее изменить. Однако, так как сейчас скорость приходит из родительского компонента как свойство в prop, SpeedSetter теперь не имеет над ней контроля.

Как правило, в React это обычно решается путем создания «контролируемого» компонента. Так же как DOM-элемент <input> принимает и value, и onChange свойства, так и компонент SpeedSetter может принимать свойства speed и onSpeedChange из своего родительского компонента SpeedRadar.

Сейчас, когда SpeedSetter захочет обновить свою скорость speed, он вызовет this.props.onSpeedChange:


Код
        
  render() {
    onChange(e) {
      // Ранее: this.setState({speed: e.target.value});
      this.props.onSpeedChange(e.target.value)
    }
  }
    

Обратите внимание, что нет никакого особого смысла в названиях свойств speed и onSpeedChange в пользовательских компонентах. Мы могли бы назвать их как угодно, например, value и onChange, что не противоречит общему соглашению.

Свойства onSpeedChange и speed будут предоставлены совместно родительским компонентом SpeedRadar. Он обработает изменение с помощью модификации своего локального состояния, что вызовет перерисовку обоих установщиков SpeedSetter с новыми значениями скорости. Мы посмотрим на новую реализацию SpeedRadar очень скоро.



Перед погружением в анализ изменений в компоненте SpeedRadar, давайте прорезюмируем наши изменения в компоненте SpeedSetter. Мы удалили из него локальное состояние, и вместо чтения значения this.state.speed, сейчас мы читаем значение this.props.speed. Вместо вызова this.setState(), когда мы хотим сделать изменение скорости, мы сейчас вызываем this.props.onSpeedChange(), который будет предоставляться компонентом SpeedRadar:


Код
        
  class SpeedSetter extends React.Component {
    constructor(props) {
      super(props);
      this.onChange = this.onChange.bind(this);
    }

    onChange(e) {
      this.props.onSpeedChange(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: 'KM'
  }
    

Если далее мы введем в поле «Миль/ч» значение 80, состояние SpeedRadar будет:


Код
        
  {
    speed: '80',
    unit: 'MPH'
  }
    

Мы могли бы хранить значения обоих установщиков, но это оказывается необязательным. Достаточно хранить значение недавнего установщика и единицу измерения, которую он представляет. Далее мы можем определить значение другого установщика, основываясь на текущей скорости speed и его единице измерения unit.

Состояния установщиков синхронизированы, так как их значения вычислены из одного и того же состояния:


Код
        
  class SpeedRadar extends React.Component {
    constructor(props){
      super(props);
      this.onSpeedInKphChange = this.onSpeedInKphChange.bind(this);
      this.onSpeedInMphChange = this.onSpeedInMphChange.bind(this);
      this.state = {speed: 0, unit: 'KPH'};
    }

    MAX_SPEED_IN_CITY_IN_KPH = 60;

    onSpeedInKphChange(speed) {
      this.setState({unit: 'KPH', speed});
    }

    onSpeedInMphChange(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} onSpeedChange={this.onSpeedInKphChange}/>
          <SpeedSetter unit="MPH" speed={mph} onSpeedChange={this.onSpeedInMphChange}/>
          <SpeedDetector speed={kph} unit="KPH" maxSpeed={this.MAX_SPEED_IN_CITY_IN_KPH}/>
        </div>
      );
    }
  }
    

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


Сейчас не имеет значения в какое поле вы вводите значение: this.state.speed и this.state.unit в компоненте SpeedRadar будут обновлены. Один из элементов input получает значение как есть, поэтому любое введенное в него значение пользователя сохраняется, а значение другого input всегда будет вычислено на основании первоначального.

Давайте прорезюмируем, что происходит, когда мы редактируем input:

  • React вызывает функцию, указанную в атрибуте onChange DOM-элемента <input>. В нашем случае, это метод onChange в компоненте SpeedSetter.
  • Метод onChange в компоненте SpeedSetter вызывает this.props.onSpeedChange() с новым требуемым значением. Эти свойства, включая onSpeedChange, были предоставлены его родительским компонентом - SpeedRadar.
  • Когда SpeedRadar отрисовывался в предыдущий раз, он указал, что onSpeedChange компонента SpeedSetter с единицами «Км/ч» является методом onSpeedInKphChange компонента SpeedRadar, а onSpeedChange компонента SpeedSetter с единицами «Миль/ч» соответственно является методом onSpeedInMphChange компонента SpeedRadar. Таким образом каждый из этих двух методов SpeedRadar будет вызван, в зависимости от того, какой <input> мы отредактировали.
  • Внутри этих методов, компонент SpeedRadar запрашивает React перерисовать себя с помощью вызова this.setState() с новым введенным значением скорости и единицей измерения скорости элемента <input>, который мы только что отредактировали.
  • React вызывает метод render() компонента SpeedRadar, чтобы понять как должен выглядеть UI. Значения обоих элементов <input> пересчитываются, на основании текущего значения скорости и активной единицы измерения. Здесь же выполняется и конвертация скорости.
  • React вызывает методы render() компонентов SpeedSetter индивидуально с их новыми свойствами, указанными компонентом SpeedRadar. Так он узнает, как должен выглядеть их UI.
  • React DOM обновляет DOM, чтобы привести в соответствие требуемые введенные значения. Элемент <input>, который мы только что отредактировали, принимает его текущее значение, а второй <input> обновляется к значению скорости после конвертации.

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


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

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

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

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

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