Всплытие состояния (Lifting State Up)

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

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

Мы начнем с компонента под названием BoilingVerdict. Он принимает температуру celsius в качестве свойства, и выводит результат: вскипит ли вода:

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.value.

Кроме того, он оказывает BoilingVerdict для текущего значения value .

class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {value: ''};
  }

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

  render() {
    const value = this.state.value;
    return (
      <fieldset>
        <legend>Enter temperature in Celsius:</legend>
        <input
          value={value}
          onChange={this.handleChange} />
        <BoilingVerdict
          celsius={parseFloat(value)} />
      </fieldset>
    );
  }
}

Попробовать на CodePen.

Добавление второго поля ввода

У нас появляется новое требование: в дополнение к вводу градусов по Цельсию, обеспечить ввод температуры в градусах по Фаренгейту и синхронизировать поля ввода.

Мы можем начать с извлечения компонента TemperatureInput из Calculator . Мы добавим новое свойство  scale, которое может быть либо  "c" либо "f":

const scaleNames = {
  c: 'Celsius',
  f: 'Fahrenheit'
};

class TemperatureInput extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {value: ''};
  }

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

  render() {
    const value = this.state.value;
    const scale = this.props.scale;
    return (
      <fieldset>
        <legend>Enter temperature in {scaleNames[scale]}:</legend>
        <input value={value}
               onChange={this.handleChange} />
      </fieldset>
    );
  }
}

Ещё мы изменим  Calculator для отображения двух отдельных полей ввода температуры:

class Calculator extends React.Component {
  render() {
    return (
      <div>
        <TemperatureInput scale="c" />
        <TemperatureInput scale="f" />
      </div>
    );
  }
}

Попробовать на CodePen.

Мы имеем два поля ввода, но при вводе температуры в одно из них, второе не меняется.  Это противоречит нашим требованиям: мы хотим их синхронизировать.

Также, мы не хотим отображать  BoilingVerdict из Calculator.  Calculator не знает о текущей температура, потому что она спрятана внутри TemperatureInput.

Всплытие состояния

Первым делом, мы напишем две функции, которые будут конвертировать температуру из Цельсия в Фаренгейт и обратно:

function toCelsius(fahrenheit) {
  return (fahrenheit - 32) * 5 / 9;
}

function toFahrenheit(celsius) {
  return (celsius * 9 / 5) + 32;
}

Эти две функции преобразуют числа. Мы напишем другую функцию, которая принимает строку value  и функцию преобразователь в качестве аргументов и вернем строку. Мы будем использовать её для вычисления значения одного ввода, основанный на другого.

Она возвращает пустую строку на недопустимое value, и сохраняет округлённое до третьего знака после запятой значение:

function tryConvert(value, convert) {
  const input = parseFloat(value);
  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'.

Следующим шагом удалим состояние из TemperatureInput.

Вместо этого, он будет получать и value , и обработчик onChange как свойство:

class TemperatureInput extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
  }

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

  render() {
    const value = this.props.value;
    const scale = this.props.scale;
    return (
      <fieldset>
        <legend>Enter temperature in {scaleNames[scale]}:</legend>
        <input value={value}
               onChange={this.handleChange} />
      </fieldset>
    );
  }
}

Если несколько компонентов, должны иметь доступ к одному и тому же состоянию, это признак того, что вместо этого состояние должно быть поднято до их ближайшего общего предка. В нашем случае это Calculator. Мы будем хранить текущее value и scale в их состоянии.

Мы могли бы хранить значение обоих вводов, но это ненужно: достаточно хранить значение последнего ввода и величину scale представления. После этого мы можем вывести значение другого ввода на основе текущего и величины scale.

Входы остаются синхронизированными, потому что их значения вычисляются из одного и того же состояния:

 

class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
    this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
    this.state = {value: '', scale: 'c'};
  }

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

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

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

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

Попробовать на CodePen.

Теперь, независимо от того, к какому ввод будем редактировать, this.state.value и this.state.scale в Calculator не обновляются. Один из вводов получает значение, а второй всегда пересчитывает.

Уроки пройдены

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

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

Если что-то может быть получена либо из свойства или состояния,  то скорее всего в состоянии этого быть не должно. Например, вместо того, чтобы хранить celsiusValue и fahrenheitValue, мы сохраняем только последнее отредактированное значение и его формат. Значение другого ввода всегда можно вычислить в методе render() . Это позволяет нам точно применить округление к другому полю без потери точности во входных данных пользователя.

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

Monitoring State in React DevTools