Перадача стану ўверх (Lifting State Up)

Часта, адно і тое ж зьмяненьне даных павінна адлюстроўвацца некалькімі кампанэнтамі. Мы рэкамэндуем узьняцьце агульнага стану (state) да іхняга бліжэйшага агульнага продка. Давайце паглядзім, як гэта працуе ў дзеяньні.

У гэтым разьдзеле мы створым калькулятар тэмпэратуры, які вылічвае ці будзе вада кіпець пры дадзенай тэмпэратуры.

Мы пачнем з кампанэнта, які называецца BoilingVerdict. Ён прымае тэмпэратуру celsius у якасьці ўласьцівасьці (prop) ды высьвечвае на экране, ці дастатковая яна, каб закіпяціць ваду:

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

Паспрабуйце на CodePen.

Дадаваньне другога ўводу (Adding a Second Input)

Нашае новае патрабаваньне заключаецца ў тым, што ў дадатак да ўводу Celsius, мы забясьпечваем увод Fahrenheit, і яны знаходзяцца ў сынхранізацыі.

Мы пачнем з экстрагаваньня кампанэнта 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 = {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, каб візуалізаваць два асобныя ўводы тэмпэратуры:

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

Паспрабуйце на CodePen.

Цяпер у нас ёсьць два ўводы, але калі вы ўведзяце ў адзін зь іх тэмпэратуру, то другі не абновіцца. Гэта супярэчыць нашаму патрабаваньню: мы хочам трымаць іх у сынхранізацыі.

Таксама мы ня можам адлюстраваць BoilingVerdict з Calculator. Calculator ня ведае бягучую тэмпэратуру, паколькі яна схаваная ўнутры TemperatureInput.

Напісаньне функцыяў пераўтварэньня (Writing Conversion Functions)

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

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

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

Гэтыя дзьве функцыі пераўтвараюць лікі. Мы напішам іншую функцыю, якая прымае радок temperature ды функцыю-канвэртар у якасьці аргумэнтаў і вяртае радок. Мы будзем выкарыстоўваць яе для вылічэньня значэньня аднаго ўводу на аснове другога ўводу.

Яна трымае вывад акругленым да трэцяга дзесятковага знака, або вяртае пусты радок пры недапушчальным значэньні 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'.

Перадача стану ўверх (Lifting State Up)

На цяперашні час абодва кампанэнты TemperatureInput незалежна захоўваюць свае значэньні ў лякальным стане (local 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;
    // ...  

Тым ня менш, мы хочам, каб гэтыя два ўводы былі сынхранізаваныя. Калі мы абнаўляем увод Celsius, то ўвод Fahrenheit павінен адлюстраваць пераўтвораную тэмпэратуру, і наадварот.

У React’е сумеснае карыстаньне станам (state) дасягаецца перамяшчэньнем яго ўверх да бліжэйшага агульнага продка кампанэнтаў, якія маюць у гэтым патрэбу. Гэта называецца “падняцьцем стану ўверх (lifting state up)”. Мы выдалім лякальны стан (local state) з TemperatureInput ды зьмесьцім яго ў Calculator.

Калі агульным станам валодае Calculator, то ён становіцца “крыніцай праўды (source of truth)” для бягучай тэмпэратуры ў абодвух уводах. Ён можа даручыць ім абодвум мець значэньні, якія адпавядаюць адно аднаму. Пасьля таго, як уласьцівасьці (props) абодвух кампанэнтаў TemperatureInput паступаюць з аднаго і таго ж бацькоўскага кампанэнта Calculator, гэтыя два ўводы будуць заўсёды сынхранізаваныя.

Давайце паглядзім, як гэта працуе крок за крокам.

Па-першае, мы заменім this.state.temperature на this.props.temperature у кампанэнце TemperatureInput. Цяпер, давайце ўявім, што this.props.temperature ужо існуе, хоць мы павінны перадаць яго з Calculator у будучыні:

  render() {
    // Before: const temperature = this.state.temperature;
    const temperature = this.props.temperature;
    // ...

Мы ведаем, што уласьцівасьці (props) толькі для чытаньня. Калі temperature была ў лякальным стане, то TemperatureInput мог проста выклікаць this.setState(), каб яе зьмяніць. Аднак цяпер, калі temperature прыходзіць ад бацькоўскага кампанэнта як ўласьцівасьць (prop), TemperatureInput ня мае ніякага кантролю над ёй.

У React’е гэта звычайна вырашаецца за кошт ператварэньня кампанэнта ў “кантраляваны (controlled)”. Гэтак жа, як DOM <input> прымае і value, і ўласьцівасьць (prop) onChange, так жа можа спэцыяльна для гэтага зроблены TemperatureInput прымаць абедзьве ўласьцівасьці (props) temperature ды onTemperatureChange ад іхняга бацькоўскага элемэнта Calculator.

Цяпер, калі TemperatureInput захоча абнавіць сваю тэмпэратуру, то ён выкліча this.props.onTemperatureChange:

  handleChange(e) {
    // Раней: this.setState({temperature: e.target.value});
    this.props.onTemperatureChange(e.target.value);
    // ...

Заўвага:

Няма спэцыяльнага значэньня ў назвах ні уласьцівасьці (prop) temperature, ні ўласьцівасьці (prop) onTemperatureChange у спэцыяльна для гэтага зробленых кампанэнтах. Мы маглі б назваць іх як-небудзь яшчэ, напрыклад, даць ім імёны value ды onChange, якія зьяўляюцца агульнапрынятымі.

Уласьцівасьць (prop) onTemperatureChange будзе пастаўляцца разам з уласьцівасьцю (prop) temperature бацькоўскім кампанэнтам Calculator. Ён будзе апрацоўваць зьмяненьне мадыфікуючы свой уласны лякальны стан (local state), такім чынам, перавізуалізоўваючы (re-rendering) абодва ўводы (inputs) з новымі значэньнямі. Вельмі хутка мы разгледзім новую рэалізацыю Calculator.

Перад пагружэньнем у зьмены ў Calculator’ы, давайце зробім рэзюмэ праведзеных намі зьменаў у кампанэнт TemperatureInput. Мы выдалілі зь яго лякальны стан (local state), а замест чытаньня this.state.temperature, мы чытаем цяпер this.props.temperature. Замест выкліканьня this.setState(), калі мы хочам унесьці зьмену, то цяпер мы выклікаем this.props.onTemperatureChange(), які пастаўляецца Calculator’ам:

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 ўвода ў ягоным лякальным стане (local state). Гэты стан мы “паднялі (lifted up)” з уводаў (inputs), і ён будзе служыць у якасьці “крыніцы праўды (source of truth)” для іх абодвух. Гэта мінімальнае прадстаўленьне ўсіх даных, якія нам трэба ведаць для таго, каб візуалізаваць (render) абодва ўводы (inputs).

Напрыклад, калі мы ўвядзем 37 ува ўвод Цэльсія (Celsius input), то стан кампанэнта Calculator будзе:

{
  temperature: '37',
  scale: 'c'
}

Калі ў далейшым мы адрэдагуем поле Фарэнгейта (Fahrenheit), каб было 212, то стан Calculator’а будзе:

{
  temperature: '212',
  scale: 'f'
}

Мы маглі б захоўваць значэньні абодвух уводаў (inputs), але ў гэтым няма патрэбы. Дастаткова захоўваць значэньне апошняга зьмененага ўвода (input) ды шкалы (scale), якая яго прадстаўляе. Затым мы можам вывесьці значэньне другога ўвода (input) на аснове толькі бягучых temperature ды scale.

Уводы застаюцца сынхранізаванымі, таму што іхнія значэньні вылічваюцца з аднаго і таго ж стану (state):

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

Паспрабуйце на CodePen.

Цяпер, незалежна ад таго, які ўвод (input) вы рэдагуеце, this.state.temperature ды this.state.scale абнаўляюцца ў Calculator’ы. Адзін з уводаў атрымлівае значэньне як ёсьць, так што ўведзенае карыстальнікам значэньне захоўваецца, а значэньне другога ўвода заўсёды пералічваецца на аснове ўведзенага.

Давайце зробім рэзюмэ таго, што адбываецца, калі вы рэдагуеце ўвод (input):

  • React выклікае функцыю, азначаную як атрыбут onChange DOM-элемэнта <input>. У нашым выпадку гэта мэтад handleChange у кампанэнце TemperatureInput.
  • Мэтад handleChange у кампанэнце TemperatureInput выклікае this.props.onTemperatureChange() з новым жаданым значэньнем. Ягоныя ўласьцівасьці (props), уключаючы onTemperatureChange, былі прадстаўлены ягоным бацькоўскім кампанэнтам — Calculator.
  • Калі ён візуалізаваўся папярэдні раз, Calculator указваў, што onTemperatureChange кампанэнта Celsius TemperatureInput зьяўляецца мэтадам handleCelsiusChange кампанэнта Calculator, а onTemperatureChange кампанэнта Fahrenheit TemperatureInput зьяўляецца мэтадам handleFahrenheitChange кампанэнта Calculator. Так, што выклікаецца той, ці іншы з гэтых двух мэтадаў Calculator’а, у залежнасьці ад таго, які ўвод (input) мы адрэдагавалі.
  • Унутры гэтых двух мэтадаў кампанэнт Calculator запытвае React перавізуалізаваць (re-render) сябе выкліканьнем this.setState() з новым уведзеным значэньнем (input value) і бягучай шкалой (scale) увода (input), які мы толькі што адрэдагавалі.
  • React выклікае мэтад render кампанэнта Calculator, каб даведацца, як павінен выглядаць UI. Значэньні абодвух ўводаў (inputs) пералічваюцца на падставе бягучай тэмпэратуры ды актыўнай шкалы. Канвэрсія тэмпэратуры выконваецца тут.
  • React выклікае мэтады render індывідуальных кампанэнтаў TemperatureInput зь іхнімі новымі ўласьцівасьцямі (props), указанымі кампанэнтам Calculator. Ён вывучае, як павінен выглядаць ягоны UI.
  • React DOM абнаўляе DOM, каб ён адпавядаў жаданым уваходным значэньням (input values). Увод (input), які мы толькі што адрэдагавалі, атрымлівае ягонае бягучае значэньне, а іншы ўвод (input) абнаўляецца да тэмпэратуры паводле канвэрсіі.

Кожнае абнаўленьне праходзіць праз такія ж ступені, так што ўводы (inputs) застаюцца сынхранізаваныя.

Атрыманыя ўрокі (Lessons Learned)

Павінна быць адзіная “крыніца праўды (source of truth)” для любых даных, якія зьмяняюцца, у прыкладной React-праграме. Звычайна стан (state) спачатку дадаецца да кампанэнта, які мае патрэбу ў ім для рэндэрынгу. Затым, калі ён таксама патрэбны іншым кампанэнтам, то вы можаце падняць яго да іхняга бліжэйшага агульнага продка. Замест таго, каб спрабаваць сынхранізаваць стан (state) паміж рознымі кампанэнтамі, вам трэба пакласьціся на зыходзячы паток даных (top-down data flow).

Падняцьце стану (state) уключае ў сябе напісаньне больш “шаблённага (boilerplate)” коду, падыходы з двухбаковай прывязкай (two-way binding), але карысным зьяўляецца тое, што менш працы ідзе на пошук і ізаляцыю памылак. Так як любы стан “жыве” ў нейкім кампанэнце й адзін толькі гэты кампанэнт можа зьмяняць яго, то плошча паверхні для памылак значна зьмяншаецца. Акрамя таго, мы можаце рэалізаваць любую карыстальніцкую лёгіку, каб адхіліць або трансфармаваць карыстальніцкі ўвод (user input).

Калі нешта можа быць атрымана альбо з уласьцівасьцяў (props), альбо са стану (state), то верагодна, што яно не павінна быць у стане (state). Напрыклад, замест таго, каб захоўваць і celsiusValue і fahrenheitValue, мы захоўваем толькі апошнюю адрэдагаваную temperature ды яе scale. Значэньне другога ўваходу заўсёды можа быць вылічана зь іх у мэтадзе render(). Гэта дазваляе нам ачышчаць або акругляць другое поле бяз страты дакладнасьці ў карыстальніцкім ўводзе (user input).

Калі вы бачыце нешта няправільнае ў UI, то можаце выкарыстоўваць React Developer Tools, каб правяраць уласьцівасьці (props), ды рухацца ўверх па дрэве, пакуль ня знойдзеце кампанэнт, адказны за абнаўленьне стану (state). Гэта дазволіць вам адсочваць памылкі да іхняй крыніцы:

Monitoring State in React DevTools