Что писать в компонентах, что — в контейнерах, а что — в экшнах

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

В мире React придумали решать эту проблему с помощью архитектуры Flux. У нас для работы с данными и состоянием приложения используется одновременно библиотеки Redux (локальное состояние приложения) и Relay (данные из бекенда). Этот документ регламентирует правила, по которым мы описываем бизнес-логику приложения.

View-Компонент

Компоненты, которые располагаются в папках components (view-компоненты), должны решать только три задачи:

  1. Корректно отображать состояние, пришедшее через свойства (props) в виде пользовательского интерфейса.
  2. Подписываться на пользовательские события (клики, ввод данных, скроллы и т.п.) и вызывать соответствующие экшны.
  3. Выполнять задачи, которые решаются специфично именно для этого типа представления: обработчики жизненного цикла компонента, управление скролл-позицией и т.п.

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

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

Вызов экшна в компоненте должен быть очень простым и коротким и может иметь две формы:

  • Вызов колбек-функции, которая передана в компонент через свойства (props) из компонента-контейнера (аннотация @container) или проброшен из родительского компонента. Этот способ предпочтительный.
  • Вызов redux-функции dispatch (аннотация @connect) с нужным экшн-креатором, импортированным из папки actions или через DI (аннотация @injectProps). Этот способ следует применять только в случаях, когда вызываемый экшн специфичен только для данного типа представления и не может быть описан в контейнере.

Пример view-компонента:

SomeComponent.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import SomeContainer, {ContainerProps} from 'common/containers/SomeContainer'

import * as someActions from '../actions/someActions'


interface ReduxProps {
  showHint?: boolean
  toggleSomeHint?: () => void
}

@container(SomeContainer)
@connect(mapStateToProps, someActions)
class SomeComponent extends React.Component<ContainerProps & ReduxProps, void> {

  render() {
    const {showHint, toggleSomeHint, somePropFromContainer} = this.props

    return (
      <div className={cx({hidden: !showHint})}
           onClick={toggleSomeHint}>
        Some hint
      </div>
      <div onClick={this.handleClick}>
        Действие {somePropFromContainer}
      </div>
    )
  }

  handleClick = () => {
    // это вырожденный случай, его можно было и напрямую в jsx прописать,
    // но обычно тут надо что-нибудь из DOM достать
    this.props.doTheRightThing()
  }
}

function mapStateToProps(state: GlobalState) {
  return {
    showHint: state.some.showHint,
  }
}

Компонент-контейнер

Компоненты, которые располагаются в папках containers (компоненты-контейнеры), выполняют следующие задачи:

  1. Получение необходимых данных из redux и Relay и предоставление их низлежащему view-компоненту в удобной форме простых свойств.
  2. Предоставление низлежащему view-компоненту колбек-функций, которые вызывают экшны, реализующие логику изменения состояния приложения. При этом сами экшны НЕ должны описываться непосредственно в контейнере (для этого существуют action-creator’ы в папке actions), особенно если это многоходовочка с поэтапным изменением состояния (например, типично: вызвали api, показали спиннер, дождались результата, показали ОК или ошибку).

Примечание

Может возникнуть вопрос:

A зачем нужна эта лишняя прослойка callback-функций в контейнере, когда можно вызывать экшны непосредственно из view-компонентов?

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

Зачастую, компонент-контейнер является общим для разных типов представлений (мобильное/web). К view-компоненту он подключается с помощью аннотации @container.

Пример контейнера для компонента из предыдущего раздела:

SomeContainer.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import CommonActions from '../actions/CommonActions'


interface OwnProps {
  viewer: any
}

export interface ContainerProps extends OwnProps {
  somePropFromContainer: string
  doTheRightThing: () => void
}

interface Props extends OwnProps {
  view: React.ComponentClass<ContainerProps>
  commonActions: CommonActions
}

@injectProps('commonActions')
@relayContainer({
  viewer: () => Relay.QL`
    fragment on Viewer {
      {SomeCoolMutation.getFragment('viewer')},
    }
  `,
})
@connect(mapStateToProps)
class SomeContainer extends React.Component<Props, void> {

  render() {
    renderView(this.props, {
      doTheRightThing: this.doTheRightThing,
    })
  }

  private doTheRightThing = () => {
    const {dispatch, commonActions, viewer} = this.props

    // вызываем сложное действие, передавая параметры, которыми владеет контейнер
    dispatch(commonActions.doTheRightThing(viewer))
  }
}

function mapStateToProps(state: GlobalState) {
  return {
    somePropFromContainer: state.common.actionName,
  }
}

Экшны (actions), или action-creator’ы

Примечание

В этом документе название экшн (action) используется как более удобное сокращение от action-creator. Подразумевается одно и то же.

Action-creator’ы — это функции, которые возвращают либо непосредственно структурку redux-экшна, либо thunk-action — функцию, которая внутри себя асинхронно диспатчит (dispatch) серию других redux-экшнов вперемешку с вызовами других сервисов. Экшны оформляются либо в виде плоских es6-модулей, либо, если необходимо применить DI, в виде классов-сервисов, расположенных в папке actions/ бандла.

Все асинхронные цепочки действий с промисами, многоэтапные действия и т.п., в том числе и вызовы Relay-мутаций, должны описываться в action-creator’ах.

Action-creator’ы могут быть как общими для всех типов представлений (тогда они используются в основном на уровне компонентов-контейнеров), так и специфическими для данного типа представления (например, только для web-приложения).

Пример сервиса с экшнами:

CommonActions.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import {ThunkAction} from 'redux-thunk'

import {commitUpdate} from 'utils/relay'


export default class CommonActions {

  doTheRightThing = (viewer: any): ThunkAction<void> => {
    return (dispatch) => {
      dispatch(startDoingRightThing())
      commitUpdate(new SomeCoolMutation({viewer}))
        .then(() => dispatch(rightThingDone())
        .catch(err => dispatch(rightThingFailed(err.message))
    }
  }
}