Возможно, вам не требуется производное состояние

7 Июня, 2018. Brian Vaughn (Брайан Вон)

React 16.4 включил исправление для getDerivedStateFromProps, из-за чего некоторые существующие баги в компонентах React воспроизводятся более последовательно. Если данный релиз обнажил случай, когда ваше приложение использовало анти-паттерн и перестало работать должным образом после исправления, мы сожалеем об этом. В данном посте мы объясним некоторые общие анти-паттерны с производным состоянием и предпочтительные альтернативы.

В течение долгого времени метод жизненного цикла componentWillReceiveProps был единственным способом обновления состояния в ответ на изменение свойств props без дополнительной отрисовки. В версии 16.3 мы ввели ему на смену новый метод жизненного цикла: getDerivedStateFromProps, чтобы более безопасно обеспечить те же варианты использования. В то же время мы поняли, что у людей много заблуждений о том, как использовать оба метода, и что анти-паттерны приводят к тонким и запутанным ошибкам. Исправление getDerivedStateFromProps в 16.4 делает производное состояние более предсказуемым, поэтому результаты его неправильного использования легче заметить.


Внимание!

Все анти-паттерны, описанные в этом разделе, применимы как к более старому componentWillReceiveProps, так и к новому getDerivedStateFromProps.

Данный раздел затрагивает следующие темы:


getDerivedStateFromProps служит только одной цели: он позволяет компоненту обновлять свое внутреннее состояние в результате изменения свойств. В нашем предыдущем посте были приведены некоторые примеры, такие как запись текущего направления прокрутки на основе изменения свойства offset или загрузки внешних данных, обозначенных свойством source.

Мы не привели много примеров, потому что, как правило, производное состояние следует использовать экономно. Все проблемы с производным состоянием, которые мы видели, могут быть в конечном итоге сведены к (1) безусловному обновлению состояния из свойств или (2) обновлению состояния, всякий раз, когда свойства и состояние не совпадают. (Мы рассмотрим оба варианта более подробно ниже).

  • Если вы используете производное состояние для запоминания некоторых вычислений, основанных только на текущих свойствах props, вам не требуется производное состояние. См. Что насчет запоминания? ниже.
  • Если вы обновляете производное состояние безусловно либо обновляете его, всякий раз когда свойства и состояние не совпадают, ваш компонент может слишком часто обновлять свое состояние. Читайте дальше для более подробной информации.


Термины «контролируемый» и «неконтролируемый» обычно относятся к элементам формы, но они также могут описывать, где живут данные какого-либо компонента. Данные, переданные как свойства, можно рассматривать как контролируемые (поскольку родительский компонент контролирует эти данные). Данные, которые существуют только во внутреннем состоянии, могут считаться неконтролируемыми (поскольку родитель не может напрямую их изменить).

Наиболее распространенная ошибка с производным состоянием заключается в смешении этих двух случаев: когда производное значение состояния обновляется к тому же и вызовами setState, получается, что для данных нет ни одного источника истины. Пример загрузки внешних данных, приведенный ранее, может показаться похожим, но он отличается в нескольких важных моментах. В примере загрузки есть явный источник истины как для свойства source, так и для значения loading в состоянии. Когда свойство source изменяется, состояние loading всегда должно быть переопределено. И наоборот, состояние переопределяется только тогда, когда свойство изменяется, в противном случае оно управляется компонентом.

Проблемы возникают при изменении любого из этих ограничений. Обычно это происходит в двух формах. Давайте рассмотрим обе.


Анти-паттерн: безусловное копирование свойств в состояние

Распространенным заблуждением является то, что getDerivedStateFromProps и componentWillReceiveProps вызывается только в том случае, если свойства «изменяются». Данные методы жизненного цикла вызываются всякий раз, когда перерисовывается родительский компонент, независимо от того, изменились ли свойства props. Поэтому всегда остается небезопасным безусловно переопределять состояние, используя каждый из этих методов жизненного цикла. Это приведет к тому, что обновления состояния будут потеряны.

Рассмотрим пример, демонстрирующий проблему. Есть компонент EmailInput, который отображает свойство email в состояние:


Код
    
  class EmailInput extends Component {
    state = { email: this.props.email };

    render() {
      return <input onChange={this.handleChange} value={this.state.email} />;
    }

    handleChange = event => {
      this.setState({ email: event.target.value });
    };

    componentWillReceiveProps(nextProps) {
      // Это затрёт любое локальное обновление состояния!
      // Не делайте этого!
      this.setState({ email: nextProps.email });
    }
  }
  

На первый взгляд данный компонент выглядит нормально. Состояние инициализируется значением, заданным c помощью свойств props, и обновляется при вводе значения в <input>. Но если родитель нашего компонента перерисовывается, все, что мы набрали в <input>, будет потеряно! (см. пример) Это справедливо, даже если бы мы производили сравнение nextProps.email! == this.state.email перед переустановкой значения.

В этом простом примере добавление shouldComponentUpdate для перерисовки компонента только когда свойство email изменилось, может исправить проблему. Однако на практике компоненты обычно принимают множество свойств, поэтому изменение какого-либо другого свойства будет по-прежнему вызывать перерисовку и неправильную переустановку. Свойства типа Function и Object также часто создаются встроенными, что затрудняет реализацию shouldComponentUpdate, который стабильно возвращает true только тогда, когда произошли существенные изменения. Вот демонстрация, которая показывает происходящее. Как результат, shouldComponentUpdate лучше всего использовать в качестве оптимизации производительности, а не для обеспечения корректности производного состояния.

Надеемся, теперь понятно, почему безусловное копирование свойств в состояние является плохой идеей. Прежде чем рассматривать возможные решения, давайте посмотрим на связанный с этим проблемный паттерн: что если мы обновляем состояние только при изменении свойства email?


Анти-паттерн: стирание состояния при изменении свойств

Продолжая приведенный выше пример, мы могли бы избежать случайного стирания состояния, обновляя его только при изменении props.email:


Код
    
	class EmailInput extends Component {
	  state = {
	    email: this.props.email
	  };
	
	  componentWillReceiveProps(nextProps) {
	    // Каждый раз, когда props.email изменяется, состояние обновляется.
	    if (nextProps.email !== this.props.email) {
	      this.setState({
	        email: nextProps.email
	      });
	    }
	  }
	  
	  // ...
	}
  


Внимание!

Несмотря на то, что приведенный пример демонстрирует componentWillReceiveProps, тот же антипаттерн применим и к getDerivedStateFromProps.

Мы только что произвели большое улучшение. Теперь наш компонент будет стирать то, что мы набрали, только когда свойства действительно изменятся.

Существует еще одна тонкая проблема. Представьте приложение-менеджер паролей, использующее вышеуказанный компонент ввода. При навигации между деталями для двух аккаунтов с тем же адресом электронной почты input не сможет быть сброшен. Это связано с тем, что значение свойства, переданное компоненту, будет одинаковым для обеих учетных записей! Это стало бы сюрпризом для пользователя, так как несохраненное изменение одной учетной записи повлияло бы на другие учетные записи, которые могли совместно использовать один и тот же адрес электронной почты. (см. демонстрацию здесь.)

Данный дизайн принципиально ошибочен, но вместе с тем это непростая ошибка. (Я сам её допустил!) К счастью, существует две альтернативы, которые работают лучше. Ключ обеих состоит в том, что для каждой части данных вам нужно выбрать один компонент, который владеет ей как источник истины, и избегать её дублирования в других компонентах. Давайте посмотрим на каждую из альтернатив.



Рекомендация: полностью контролируемый компонент

Один из способов избежать упомянутых выше проблем - полностью удалить состояние из нашего компонента. Если адрес электронной почты существует только как свойство, то нам не нужно беспокоиться о конфликтах с состоянием. Мы даже могли бы преобразовать EmailInput в более легкий функциональный компонент:


Код
    
 function EmailInput(props) {
   return <input onChange={props.onChange} value={props.email} />;
 }
  

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


Рекомендация: полностью неконтролируемый компонент с ключом key

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


Код
    
  class EmailInput extends Component {
    state = { email: this.props.defaultEmail };
  
    handleChange = event => {
      this.setState({ email: event.target.value });
    };
  
    render() {
      return <input onChange={this.handleChange} value={this.state.email} />;
    }
  }
  

Чтобы сбросить значение при переходе на другой элемент (как в нашем сценарии менеджера паролей), мы можем использовать специальный атрибут React, называемый key. Когда key изменяется, React создает новый экземпляр компонента, а не обновляет текущий. Обычно ключи используются для динамических списков, но они также полезны и здесь. В нашем случае мы можем использовать ID пользователя для пересоздания элемента ввода электронной почты при выборе нового пользователя:


Код
    
  <EmailInput
      defaultEmail={this.props.user.email}
      key={this.props.user.id}
  />
  

Каждый раз, когда ID изменяется, элемент ввода почтового адреса EmailInput будет создан заново, и его состояние будет сброшено до последнего значения defaultEmail. (Нажмите здесь, чтобы увидеть демонстрацию этого шаблона.) При таком подходе вам не нужно добавлять ключ к каждому элементу ввода. Возможно, было бы разумнее установить ключ на всю форму. Каждый раз, когда ключ изменяется, все элементы в форме будут созданы заново со свеже инициализированным состоянием.

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


Внимание!

Хотя такое решение может показаться медленным, разница в производительности обычно незначительная. Использование key может оказаться даже быстрее, в случаях когда компоненты имеют тяжелую логику, которая срабатывает при обновлениях, произошедших после того как алгоритм сравнения обошел соответствующее поддерево.


Альтернатива 1: Сброс неконтролируемого компонента с помощью свойства-идентификатора

Если по какой-либо причине key не работает (возможно, инициализировать компонент очень затратно), то рабочим, но громоздким решением будет слежение за изменениями userID в getDerivedStateFromProps:


Код
    
  class EmailInput extends Component {
      state = {
        email: this.props.defaultEmail,
        prevPropsUserID: this.props.userID
      };

      static getDerivedStateFromProps(props, state) {
        // Каждый раз, когда текущий пользователь изменяется,
        // сбрасываем необходимую часть состояния, связанную с этим пользователем.
        // В нашем примере э то email
        if (props.userID !== state.prevPropsUserID) {
          return {
            prevPropsUserID: props.userID,
            email: props.defaultEmail
          };
        }
        return null;
      }

      // ...
  }
  

Данное решение предоставляет гибкость: можно сбрасывать только требуемые части внутреннего состояния компонента по заданному условию. (Нажмите здесь, чтобы увидеть демонстрацию данного шаблона.)


Альтернатива 2: Сброс неконтролируемого компонента с помощью метода экземпляра

Реже, вам может потребоваться сбросить состояние, даже если нет подходящего идентификатора, чтобы использовать его как key. Одним из решений может являться сброс ключа на случайное значение или инкрементация номера каждый раз, когда вы хотите сбросить состояние. Но существует и еще одна жизнеспособная альтернатива: можно опубликовать метод экземпляра для императивного сброса внутреннего состояния:


Код
    
    class EmailInput extends Component {
      state = {
        email: this.props.defaultEmail
      };

      resetEmailForNewUser(newEmail) {
        this.setState({ email: newEmail });
      }

      // ...
    }
  

Далее родительский компонент формы мог бы использовать ref для вызова данного метода. (Нажмите здесь, чтобы увидеть демонстрацию данного шаблона.)

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


Резюме

Напомним, что при разработке компонента важно решить какими будут его данные: контролируемые или неконтролируемые.

Вместо того, чтобы пытаться «отзеркаливать» значение свойства в состояние, сделайте компонент контролируемым и объедините два различных значения в состоянии какого-либо родительского компонента. Например, вместо того, чтобы потомок принимал «фиксированное» props.value и отслеживал «мгновенное» state.value, пусть родительский управляет как state.draftValue, так и state.committedValue и напрямую управляет значением потомка. Это сделает поток данных более явным и предсказуемым.

В случае неконтролируемых компонентов, если вы пытаетесь сбросить состояние, когда изменяется определенное свойство (обычно идентификатор), у вас есть несколько вариантов:

  • Рекомендация: сбрасывать все внутреннее состояние используя атрибут key.
  • Альтернатива 1: для сброса только определенных полей состояния отслеживайте изменения специального свойства (например, props.userID).
  • Альтернатива 2: объявление императивного метода экземпляра и использование ссылок.


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

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

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


Код
    
    class Example extends Component {
      state = {
        filterText: "",
      };

      // *******************************************************
      // Внимание: данный пример не является рекомендованным подходом.
      // Смотрите рекоммендуемые нами примеры ниже.
      // *******************************************************
    
      static getDerivedStateFromProps(props, state) {
        // Переустанавливать фильтр каждый раз, когда список или текст фильтра изменяется.
        // Обратите внимание, что нам необходимо хранить prevPropsList и
        // prevFilterText, чтобы регистрировать изменения.
        if (
          props.list !== state.prevPropsList ||
          state.prevFilterText !== state.filterText
        ) {
          return {
            prevPropsList: props.list,
            prevFilterText: state.filterText,
            filteredList: props.list.filter(item => item.text.includes(state.filterText))
          };
        }
        return null;
      }
    
      handleChange = event => {
        this.setState({ filterText: event.target.value });
      };
    
      render() {
        return (
          <Fragment>
            <input onChange={this.handleChange} value={this.state.filterText} />
            <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
          </Fragment>
        );
      }
    }
  

Данная реализация позволяет избежать перерасчета filteredList чаще, чем это необходимо. Но здесь присутствует избыточная сложность, так как нужно отдельно отслеживать изменения и в props, и в state, чтобы правильно обновлять отфильтрованный список. В следующем примере мы можем упростить работу с помощью PureComponent и перемещения операции фильтрации в метод render:


Код
    
  // Изменение определяется при помощи проведения поверхностного сравнения ключей state и props.
  class Example extends PureComponent {
    // В состоянии требуется хранить только текущий текст фильтра:
    state = {
      filterText: ""
    };
  
    handleChange = event => {
      this.setState({ filterText: event.target.value });
    };
  
    render() {
      // Метод render в данном наследнике PureComponent вызывается только если
      // props.list или state.filterText изменились.
      const filteredList = this.props.list.filter(
        item => item.text.includes(this.state.filterText)
      )
  
      return (
        <Fragment>
          <input onChange={this.handleChange} value={this.state.filterText} />
          <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
        </Fragment>
      );
    }
  }
  

Вышеупомянутый подход намного яснее и проще, чем версия с производным состоянием. Но иногда и это не будет являться достаточно хорошим решением - фильтрация может оказаться медленной для больших списков, а PureComponent не будет препятствовать перерисовке, если какое-либо дополнительное свойство изменилось. Чтобы решить обе эти проблемы, мы могли бы добавить memoization помощник, чтобы избежать излишней повторной фильтрации нашего списка:


Код
    
  import memoize from "memoize-one";

  class Example extends Component {
    // В состоянии требуется хранить только текущий текст фильтра:
    state = { filterText: "" };

    // Переустанавливать фильтр каждый раз, когда список или текст фильтра изменяется:
    filter = memoize(
      (list, filterText) => list.filter(item => item.text.includes(filterText))
    );

    handleChange = event => {
      this.setState({ filterText: event.target.value });
    };

    render() {
      // Вычислить последний отфильтрованный список. Если эти аргументы не изменились
      // с последней отрисовки, `memoize-one` будет повторно использовать последнее возвращенное значение.
      const filteredList = this.filter(this.props.list, this.state.filterText);

      return (
        <Fragment>
          <input onChange={this.handleChange} value={this.state.filterText} />
          <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
        </Fragment>
      );
    }
  }
  

Данное решение намного проще и достигает той же цели, что и версия с производным состоянием!

При использовании мемоизации помните о некоторых ограничениях:

  • В большинстве случаев вам нужно присоединить мемоизированную функцию к экземпляру компонента. Это предотвращает множество экземпляров компонента от переустановки мемоизированных ключей друг друга.
  • Как правило вам понадобится memoization помощник с ограниченным размером кеша, чтобы предотвратить утечку памяти с течением времени. (В приведенном выше примере мы использовали memoize-one, поскольку он кэширует только самые недавние аргументы и результат).
  • Ни одна из реализаций, представленных в этом разделе, не будет работать, если props.list пересоздается каждый раз, когда отрисовывается родительский компонент. Но в большинстве случаев они подходят.


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

Также стоит напомнить, что метод getDerivedStateFromProps (и производное состояние в целом) является продвинутой функцией и должен использоваться экономно из-за описанной сложности. Если ваш случай использования выходит за рамки данных шаблонов, пожалуйста, поделитесь им с нами в или Twitter!