Статьи

Redux или нет: искусство структурирования состояния в приложениях React

Одна общая тенденция, которую я нахожу среди большинства разработчиков Redux, — это ненависть к setState() Многие из нас (да, я попадал в эту ловушку много раз раньше) вздрагивают при виде setState() Но по мере роста сложности вашего приложения возникает ряд проблем.

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

Начиная

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

Примечание: я буду использовать yarn Если у вас нет настроенной пряжи , замените пряжу на npm

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

 yarn install
yarn run start

Вы должны увидеть страницу основного списка с некоторыми из ваших любимых персонажей GoT в списке.

Примечание: мы будем использовать шаблон уток, чтобы написать наше приложение. Это уменьшает ненужный импорт модулей и сокращает объемы работы.

Введение в Redux

Цель этой статьи — помочь вам структурировать ваши приложения Redux. Предполагает базовые знания библиотеки. Я дам краткий обзор концепций Redux, которые помогут вам лучше следовать остальной части статьи. Если вы знакомы с тем, как это работает, не стесняйтесь пропустить этот раздел.

Все приложения Redux используют четыре важных конструкции: действия, редукторы, хранилище и контейнеры.

действия

Действие — это намерение обновить состояние. Это может быть вызвано сетевым вызовом или нажатием кнопки пользователем. Действия состоят из двух частей:

  1. Тип действия . Уникальный идентификатор, представляющий действие.
  2. Полезная нагрузка Любые метаданные, связанные с действием. Например, если мы сделаем сетевой запрос на получение списка фильмов, ответ от сервера будет полезной нагрузкой.

В этом примере мы будем использовать библиотеку под названием redux-actions

Переходники

Редуктор — это функция, которая прослушивает действие и возвращает новое представление состояния.

хранить

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

Контейнеры

Контейнеры связывают состояние вашего приложения и действия с компонентом, передавая их как реквизиты.

Чтобы глубже понять, как это работает, я бы посоветовал вам сначала взглянуть на бесплатную вводную серию Дэна Абрамова .

Разделение данных приложения и состояния пользовательского интерфейса

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

 //GoTCharacter.js

export const CharacterRow = ({character}) => (
  <div className="row">
    <div className="name">{character.name}</div>
    <div className="description">{character.description}</div>

  </div>
);

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

Подход setState

Самый простой способ добиться этого в React — использовать setState()

 //GoTCharacter.js

export class StatefulCharacterRow extends Component {
  constructor() {
    super();
    this.state = {
      show_description: false
    }
  }

  render() {
    const {character} = this.props;
    return (<div className="row">
      <div className="name">{character.name}</div>
      <a href="#" onClick={() => this.setState({
        show_description: !this.state.show_description})} >
        {this.state.show_description ? 'collapse' : 'expand'}
      </a>
      {this.state.show_description &&
        <div className="description">{character.description}</div>}

    </div>);
  }
};

Подход Redux

Использование setState() Если, например, мы хотим внедрить функцию «развернуть все», будет трудно справиться с этим только с помощью React.

Давайте посмотрим, как мы можем переместить это в Redux:

 // FlickDuck.js

// …
export const toggleCharacterDescription = createAction(
  FlixActions.TOGGLE_CHARACTER_DESCRIPTION, (character) => ({character})
);

export default (current_state, action) => {
  const state = current_state || default_state;

  switch (action.type) {
    case FlixActions.TOGGLE_CHARACTER_DESCRIPTION:
      return {...state, characters: state.characters.map(char => {
        if (char.id === action.payload.character.id) {
          return {...char,show_description: !char.show_description};
        }

        return char;
      })}
    default:
      return state
  }
}
 // GoTCharactersContainer.js

import { connect } from 'react-redux';
import GoTCharacters from './GoTCharacters';
import {toggleCharacterDescription} from './FlickDuck';

const mapStateToProps = (state) => ({
  ...state.flick
});

const mapDispatchToProps = (dispatch) => ({
  toggleCharacterDescription : (data) => dispatch(toggleCharacterDescription(data))
});

export default connect(mapStateToProps, mapDispatchToProps)(GoTCharacters);
 // GoTCharacters.js

const GoTCharacters = ({characters,toggleCharacterDescription}) => {
  return (
    <div className="characters-list">
      {characters.map(char => (
        <CharacterRow
          character={char}
          toggleCharacterDescription={toggleCharacterDescription}
          key={char.id}/>
      ))}
    </div>
  );
};

export const CharacterRow = ({character, toggleCharacterDescription}) => (
  <div className="row">
    <div className="name">{character.name}</div>
    <a href="#" onClick={toggleCharacterDescription.bind(null, character)} >
      {character.show_description ? 'collapse' : 'expand'}
    </a>
    {character.show_description &&
      <div className="description">{character.description}</div>}

  </div>
);

Мы сохраняем состояние поля описания внутри объекта персонажа. Наше государство теперь будет выглядеть так:

 state = {
  characters: [{
    id: 1,
    name: "Eddard Ned Stark",
    house: "stark",
    description: "Lord of Winterfell - Warden of the North - Hand of the King - Married to Catelyn (Tully) Stark",
    imageSuffix: "eddard-stark",
    wikiSuffix: "Eddard_Stark",
    show_description: true
  },
  {
    id: 2,
    name: "Benjen Stark",
    house: "stark",
    description: "Brother of Eddard Stark - First ranger of the Night's Watch",
    imageSuffix: "benjen-stark",
    wikiSuffix: "Benjen_Stark",
    show_description: false
  }]
}

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

До сих пор мы имели дело с персонажами из первой главы GoT, и вселенная собирается стать намного больше. Когда это произойдет, наше приложение станет медленным. Представьте себе цикл по 1000 символов для обновления одной строки.

Давайте посмотрим, как масштабировать это для большего набора данных:

 // FlickDuck.js

// …
case FlixActions.TOGGLE_CHARACTER_DESCRIPTION:
  const {character} = action.payload;
  return {
    ...state,
    character_show_description: {
      ...state.character_show_description,
      [character.id]: !state.character_show_description[character.id]
    }
  }
// …

И в GoTCharacters.js

 export const CharacterRow = ({character, character_show_description, toggleCharacterDescription}) => (
  <div className="row">
    <div className="name">{character.name}</div>
    <a href="#" onClick={toggleCharacterDescription.bind(null, character)} >
      {character_show_description[character.id] ? 'collapse' : 'expand'}
    </a>
    {character_show_description[character.id] &&
      <div className="description">{character.description}</div>}
  </div>
);

Когда пользователь нажимает на ссылку «Развернуть», мы обновляем character_show_description Состояние выглядит сейчас так:

 state = {
  characters: [...],
  character_show_description: {
    1: true,
    2: false
  }
}

Теперь мы можем обновить состояние пользовательского интерфейса без зацикливания всех символов.

Управление формой состояния в Redux

Управление формой государства — сложное дело. В типичном приложении мы будем сериализовать данные формы один раз во время отправки и, если это допустимо, отправим их. В противном случае мы покажем сообщение об ошибке. Легко-peasy, верно?

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

Давайте посмотрим, как мы можем реализовать это с Redux:

 // FlickDuck.js
// ============

const FlixActions = km({
  FETCH_CHARACTERS: null,
  TOGGLE_CHARACTER_DESCRIPTION: null,
  TOGGLE_CHARACTER_EDIT: null,
  SYNC_CHARACTER_EDIT_DATA: null,
  SAVE_CHARACTER_EDIT: null
});

const default_state = {
  characters: characters,
  character_show_description: {},
  show_character_edit: {},
  character_edit_form_data: {}
};


export const toggleEdit = createAction(
  FlixActions.TOGGLE_CHARACTER_EDIT, (character) => ({character})
);

export const syncCharacterEditData = createAction(
  FlixActions.SYNC_CHARACTER_EDIT_DATA, (character, form_data) => ({character, form_data})
);
export const editCharacterDetails = createAction(
  FlixActions.SAVE_CHARACTER_EDIT, (character) => ({character})
);

export default (current_state, action) => {
  // …

  switch (action.type) {
    // …

    case FlixActions.TOGGLE_CHARACTER_EDIT:
      character =  action.payload.character;
      const show_character_edit = !state.show_character_edit[character.id];
      return {
        ...state,
        show_character_edit: {
          ...state.show_character_edit,
          [character.id]: show_character_edit
        }, character_edit_form_data : {
          ...state.character_edit_form_data,
          [character.id]: show_character_edit ? {...character} : {}
        }
      }

    case FlixActions.SYNC_CHARACTER_EDIT_DATA:
      character =  action.payload.character;
      const {form_data} = action.payload;

      return {
        ...state,
        character_edit_form_data: {
          ...state.character_edit_form_data,
          [character.id]: {...form_data}
        }
      }

    case FlixActions.SAVE_CHARACTER_EDIT:
      character =  action.payload.character;
      const edit_form_data = state.character_edit_form_data[character.id];
      const characters = state.characters.map(char => {
        if (char.id === character.id) return {...char, name:edit_form_data.name, description: edit_form_data.description}

        return char;
      });

    return {
      ...state,
      characters,
      show_character_edit: {
        ...state.show_character_edit,
        [character.id]: false
      }
    }

    // …
  }
}
 // GotCharacters.js


export const CharacterRow = ({character, character_show_description, character_edit_form_data, show_character_edit, toggleCharacterDescription, toggleEdit, syncCharacterEditData, editCharacterDetails}) => {
  const toggleEditPartial = toggleEdit.bind(null, character);
  return (<div className="row">
    <div className="name">{character.name}</div>
    <a href="#" onClick={toggleCharacterDescription.bind(null, character)} >
      {character_show_description[character.id] ? 'collapse' : 'expand'}
    </a>
    {!character_show_description[character.id] && <a href="#" onClick={toggleEditPartial} >
      edit
    </a>}
    {character_show_description[character.id] &&
      <div className="description">{character.description}</div>}

    {show_character_edit[character.id] &&
      <EditCharacterDetails character={character}
        cancelEdit={toggleEditPartial}
        syncCharacterEditData={syncCharacterEditData}
        editCharacterDetails={editCharacterDetails}
        edit_data={character_edit_form_data[character.id]}/>
    }
  </div>);
}

export const EditCharacterDetails = ({character, edit_data, syncCharacterEditData, editCharacterDetails, cancelEdit}) =>  {
  const syncFormData = (key, e) => {
    const {value} = e.currentTarget;
    syncCharacterEditData(character, {
      ...edit_data,
      [key]: value
    });
  };

  const saveForm = (e) => {
    e.preventDefault();
    editCharacterDetails(character);
  };

  return (
    <form onSubmit={saveForm}>
      <label>Name: </label>
      <input name='name' value={edit_data.name} onChange={syncFormData.bind(null, 'name')}/>

      <label>Description:</label>
      <textarea name='description' value={edit_data.description} onChange={syncFormData.bind(null, 'description')}/>

      <button type="reset" onClick={cancelEdit}> Cancel </button>
      <button type="submit"> Submit </button>
    </form>
  );
};

Давайте расширим это для обработки проверок:

 // FlickDuck.js
// ============

export const editCharacterDetails = createAction(
  FlixActions.VALIDATE_AND_SAVE_CHARACTER_EDIT, (dispatch, character, edit_form_data) => {
    const errors = validateCharacterForm(edit_form_data);
    if (Object.keys(errors).length) {
      return dispatch(showErrorMessage(character, errors));
    }

    return dispatch(saveCharacterEdit(character));
  }
);

export const showErrorMessage = createAction(
  FlixActions.VALIDATE_CHARACTER_EDIT, (character, errors) => ({character, errors, hasError: true})
);

export const saveCharacterEdit = createAction(
  FlixActions.SAVE_CHARACTER_EDIT, (character) => ({character})
);

switch (action.type) {
  // …

  case FlixActions.VALIDATE_CHARACTER_EDIT:
    character =  action.payload.character;
    const {errors, hasError} = action.payload;

    return {
      ...state,
      character_edit_form_errors: {
        ...state.character_edit_form_errors,
        [character.id]: {errors, hasError}
      }
    }
  // …
}

Разве это не очень похоже на пример, который мы видели в предыдущем разделе? Что такого особенного в формах?

Прежде чем перейти к этому, важно понять, как работает Redux. Когда ваше состояние изменяется, вы не обновляете ни одной точки в дереве. Вместо этого все дерево состояний заменяется новым. Это дерево передается вашему компоненту React, и React согласовывает все компоненты, чтобы узнать, нужно ли обновить DOM.

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

Давайте посмотрим, как мы можем изменить это, не внося больших изменений:

 export class StatefulCharacterRow extends Component {
  constructor() {
    super();

    this.toggleEditForm = this.toggleEditForm.bind(this);
    this.syncCharacterEditData = this.syncCharacterEditData.bind(this);
    this.state = {
      show_description: false,
      show_edit_form: false,
      edit_data: {}
    }
  }

  toggleEditForm() {
    const {name, description} = this.props.character;
    const show_edit_form = !this.state.show_edit_form;
    const edit_data = show_edit_form ? {name, description} : {};
    this.setState({show_edit_form, edit_data});
  }

  syncCharacterEditData(character, form_data) {
    this.setState({
      edit_data: {...this.state.edit_data, ...form_data}
    });
  }

  render() {
    const {character} = this.props;
    return (<div className="row">
      <div className="name">{character.name}</div>
      <a href="#" onClick={() => this.setState({
        show_description: !this.state.show_description})} >
        {this.state.show_description ? 'collapse' : 'expand'}
      </a>

      {!this.state.show_edit_form && <a href="#" onClick={this.toggleEditForm} >
        edit
      </a>}
      {this.state.show_description &&
        <div className="description">{character.description}</div>}

      {this.state.show_edit_form &&
        <EditCharacterDetails character={character}
          cancelEdit={this.toggleEditForm}
          syncCharacterEditData={this.syncCharacterEditData}
          editCharacterDetails={this.props.editCharacterDetails}
          edit_data={this.state.edit_data}/> }
    </div>);
  }
};

Самый простой способ справиться с этим — создать компонент-оболочку вокруг вашей формы (представьте, что это контейнер) и сохранить там состояние. Таким образом, когда пользователь вводит изменения, только этот узел обновляется без встряхивания всего дерева.

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

Завершение

Прежде чем принять решение о том, где хранить состояние при использовании Redux, было бы полезно разобраться в следующих сценариях:

1. Это состояние пользовательского интерфейса или состояние приложения?

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

 state = {
  characters: [{
    id: 1,
    name: Jon Snow,}],
  ui_state: {
    1: {
      is_edit_in_progress: true,
      show_description: false
    }
  }
}

2. Как решить, что входит в состояние компонента, а что — в Redux

Обычно данные приложения могут отображаться на странице много раз. Например, мы можем отобразить список всех символов и показать количество символов, сгруппированных по домам, к которым они принадлежат. Имеет смысл управлять ими в Redux.

Сохранять состояние пользовательского интерфейса в Redux, если есть глобальная зависимость. В противном случае вам лучше справиться с этим с состоянием локального компонента React.

Redux помог мне лучше структурировать свои мысли. С jQuery / Backbone я сосредоточился на том, как манипулировать DOM для достижения желаемого эффекта. В Redux речь идет о правильном определении состояния приложения. Как только вы это сделаете, сложность вашей интерфейсной базы значительно снизится.