5.8 Состояние приложения


В этом разделе речь пойдёт о таком таком крайне важном в настоящее время элементе приложения как состояние. React даёт нам возможность работать с состоянием компонента. Дочерний компонент может узнать об изменении состояния родителя через props. Но как быть, когда компоненты не имеют общего предка? Приложение постоянно получает и посылает данные на сервер, как об этом может знать множество компонентов? Можно ли хранить данные приложения в каком-нибудь централизованном хранилище, чтобы к нему имели доступ желаемые компоненты? Эти и масса похожих вопросов небезосновательны. Они требуют серьёзного рассмотрения, так как неизбежно возникают по мере роста приложения.




5.8.1 Мотивация


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

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

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

С этой сложностью трудно справиться, поскольку мы смешиваем две концепции, которые человеческому разуму трудно совместить: мутация и асинхронность. Они как Ментос и Кола. Оба могут быть хороши по отдельности, но вместе создают настоящий хаос. Такие библиотеки, как React, пытаются решить эту проблему на уровне представления путем устранения как асинхронности, так и прямой манипуляции с DOM. Однако управление состоянием ваших данных остается за вами. Это как раз тот момент, когда в большую игру вступает Redux.

Следуя в одном направлении за Flux, CQRS и Event Sourcing, Redux пытается сделать изменения состояния предсказуемыми, налагая определенные ограничения на то, как и когда могут происходить обновления. Эти ограничения отражены в трех принципах Redux.

Возвращаясь к контексту нашего приложения, следует отметить, что на данный момент мы используем только локальное состояние компонента, чтобы хранить в нём какие-либо данные. Например компонент <Appointments> хранит переменную состояния date, для того чтобы поместить в неё данные, пришедшие с сервера. Для подгрузки же данных мы используем метод find() сервиса AppointmentService:


Код
    
  load() {
     this.setState({ isLoading: true })

     service
       .find({ filter: this.state.filter })
       .then(({ success, data }) => {
         if (success) {
           this.setState({
             data, isLoading: false
           })
         }
       })
   }
  

Этот код занимает 13 строк. А теперь представьте, что в одном компоненте <Assessments> нам нужно определить 5-10 подобных методов, осуществляющих подгрузку разнообразных данных с сервера. При этом состоянием компонента мы управляем вручную, используя в определённых местах вызов this.setState().

По мере того, как число сервисов и методов в них начнёт увеличиваться, код компонентов также начнёт расти. В свою очередь, число компонентов, которые вызывают методы сервисов (наподобие нашего <Assessments>) может составлять несколько десятков (а то и больше). Также многие компоненты могут использовать одни и те же методы сервисов. То есть возможно дублирование кода.

Итак, с ростом нашего React-приложения мы получим следующие проблемы:

  • Рост объема типового кода для подгрузки серверных данных

  • Дублирование кода

  • Станет сложнее находить и исправлять ошибки

  • Станет сложнее управлять состоянием компонента, ввиду увеличения числа вызовов this.setState().

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

Кстати, Redux - это не единственная библиотека, которая осуществляет работу с состоянием. Также весьма популярными являются библиотеки Flux и mobx, применяющие собственные концепции и API. Вам непременно стоит обратить на них внимание. Возможно какой-то из этих библиотек вы отдадите большее предпочтение.



5.8.2 Библиотека Redux



5.8.2.1 Базовые концепции


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


Код
    
  {
    todos: [
      { text: 'Eat food', isCompleted: true },
      { text: 'Exercise', isCompleted: false }
    ],
    visibilityFilter: 'SHOW_COMPLETED'
  }
  

Этот объект похож на «модель», однако здесь нет сеттеров. Это сделано для того, чтобы различные части кода не могли произвольно изменять состояние, что приводило бы к трудно воспроизводимым ошибкам.

Чтобы что-то изменить в состоянии, нужно отправить действие. Действие - это простой объект JavaScript, который описывает, что именно произошло. Вот несколько примеров действий:


Код
    
  { type: 'ADD_TODO', text: 'Оплатить налоги' }
  { type: 'TOGGLE_TODO', index: 1 }
  { type: 'SET_VISIBILITY_FILTER', filter: 'SHOW_ALL' }
  

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

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


Код
    
  function visibilityFilter (state = 'SHOW_ALL', action) {
    if (action.type === 'SET_VISIBILITY_FILTER') {
      return action.filter
    }

    else {
      return state
    }
  }

  function todos(state = [], action) {
    switch (action.type) {
      case 'ADD_TODO':
        return state.concat([{ text: action.text, completed: false }])
      case 'TOGGLE_TODO':
        return state.map((todo, index) =>
          action.index === index
            ? { text: todo.text, completed: !todo.completed }
            : todo
        )
      default:
        return state
    }
  }
  

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

Это и есть вся идея Redux! Обратите внимание, что мы не использовали Redux API. Redux поставляется с несколькими утилитами для упрощения этого шаблона, но основная идея заключается в том, что вы описываете, как ваше состояние обновляется с течением времени в ответ на объекты действий, и 90% кода, который вы пишете, - это простой JavaScript, без использования самого Redux, его API, или любой другой магии.


5.8.2.2 Три базовых принципа


Redux основан на трех базовых принципах:


۞ Единственный источник истины.


Состояние всего вашего приложения хранится в древовидном объекте в едином хранилище.

Это облегчает создание универсальных приложений, поскольку состояние вашего сервера может быть сериализовано и перенесено в клиент без дополнительных усилий по написанию кода. Единое дерево состояния облегчает отладку и проверку приложения. Оно также позволяет сохранять состояние вашего приложения в процессе разработки, что сильно ускоряет её цикл. Некоторые традиционно сложные в реализации функции, такие как Отменить/Повторить, могут внезапно стать тривиальными, если всё ваше состояние хранится в одном дереве.


۞ Состояние предназначено только для чтения.


Единственный способ изменить состояние - это совершить действие, отправив соответствующий объект с необходимым описанием.

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


۞ Изменения производятся чистыми функциями.


Чтобы указать, как трансформировать дерево состояния посредством действий, используются чистые функции-редюсеры.

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

Вот пример создания редюсера приложения, используя Redux API:


Код
    
  function visibilityFilter(state = 'SHOW_ALL', action) {
    switch (action.type) {
      case 'SET_VISIBILITY_FILTER':
        return action.filter
      default:
        return state
    }
  }

  function todos(state = [], action) {
    switch (action.type) {
      case 'ADD_TODO':
        return [
          ...state,
          {
            text: action.text,
            completed: false
          }
        ]
      case 'COMPLETE_TODO':
        return state.map((todo, index) => {
          if (index === action.index) {
            return Object.assign({}, todo, {
              completed: true
            })
          }
          return todo
        })
      default:
        return state
    }
  }

  import { combineReducers, createStore } from 'redux'
  const reducer = combineReducers({ visibilityFilter, todos })
  const store = createStore(reducer)
  

Это всё! Теперь вы знаете, что такое Redux.


5.8.2.3 Redux API


Мы уже познакомились с назначением и базовыми концепциями библиотеки Redux. Далее вам следует разобраться с её API. Я не буду дублировать уже имеющуюся информацию, а лишь сориентирую вас на готовые руководства.

Оригинал документации находится здесь. Также существует неплохой русскоязычный перевод. Для базового понимания Redux API вам обязательно нужно изучить следующие разделы из базового руководства:

Их вполне хватит для понимания того, что мы будем делать дальше. Ну а если хотите стать настоящим асом - изучайте все разделы оригинала. Это будет потрясающе!



5.8.3 Архитектура


Самое главное, что необходимо сделать вначале перед подключением библиотеки Redux - это задать структуру объекта состояния приложения. Исходя из своего опыта, для нашего приложения я предлагаю задать следующую структуру:


Код
    
  {
      appointment: {
          list: { // список приёмов
              error: null, // объект ошибки при загрузке данных с сервера
              isFetching: false, // флаг-индикатор загрузки данных
              shouldReload: false, // следует ли перезагрузить данные
              dataSource: {
                  data: [], // сами данные
                  filter: { // фильтр данных
                      startDate: null,
                      endDate: null,
                      clientName: '',
                      onlyMe: false
                  }
              }
          }
      }
  }
  

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


Код
    
  {
      appointment: {
          list: {
              error: null,
              isFetching: false,
              shouldReload: false,
              dataSource: {
                  data: [],
                  filter: {
                      startDate: null,
                      endDate: null,
                      clientName: '',
                      onlyMe: false
                  }
              },
              sorting: { // данные сортитровки
                  field: 'startDate',
                  order: 'asc'
              },
              pagination: { // данные пагинации
                  page: 1,
                  size: 25,
                  totalCount: 0
              }
          },
          details: { // детали приёма
              error: null,
              isFetching: false,
              shouldReload: false,
              data: null // сами данные
          },
          form: { // форма для создания/редактирования приёма
              error: null,
              isFetching: false,
              shouldReload: false,
              fields: { // поля формы
                  startDate: null,
                  endDate: null,
                  clientName: '',
                  onlyMe: false
              }
          },
          count: { // счётчик колличества (записей в таблице БД)
              error: null,
              isFetching: false,
              shouldReload: false,
              value: null
          },
          history: { // история изменений приёма (например мы несколько раз редактировали одну запись)
              // структура точно такая же как и у просто списка list
              error: null,
              isFetching: false,
              shouldReload: false,
              dataSource: {
                  data: [],
                  pagination: {
                      page: 1,
                      size: 10,
                      totalCount: 0
                  }
              }
          },
          can: { // разграничение прав
              add: { // может ли текущий пользователь добавлять запись
                  error: null,
                  isFetching: false,
                  value: null
              },
              edit: { // может ли текущий пользователь редактировать запись
                  error: null,
                  isFetching: false,
                  value: null
              },
              remove: { // может ли текущий пользователь удалять запись
                  error: null,
                  isFetching: false,
                  value: null
              }
          }
      }
  }
  

Как видно объект состояния может быть довольно большим. Здесь представлена структура лишь для всего, что касается приёмов. Но ведь приложение может содержать информацию по пользователям, аутентификации и так далее. В реальных проектах размер данного объекта может доходить до 20 и более полей самого верхнего (корневого) уровня! У нас же пока только одно такое поле: appointments.

Если вы уже хорошо ознакомились с Redux API, то используя предложенную структуру объекта состояния мы можем осуществлять доступ к хранящимся в нём данным из классов-контейнеров следующим образом:


Код
    
  // список
  this.props.appointment.list.error
  this.props.appointment.list.isFetching
  this.props.appointment.list.dataSource.data
  this.props.appointment.list.dataSource.filter
  this.props.appointment.list.dataSource.pagination
  ...

  // детали
  this.props.appointment.details.error
  this.props.appointment.details.isFetching
  this.props.appointment.details.data
  ...

  // форма
  this.props.appointment.form.error
  this.props.appointment.form.isFetching
  this.props.appointment.form.fields
  ...
  

Как видно, при доступе к необходимым данным у нас перед глазами всегда понятная и подробная структура объекта состояния. Благодаря тому, что она максимально шаблонная, её легко запомнить, и, что очень важно, в ней довольно просто сориентироваться, особенно если код пишет несколько разработчиков. Кроме того, этот код доступа к данным можно сократить, далее будет показано как это можно сделать.

Что ж, самую важную часть мы выполнили. Теперь перейдём к внедрению библиотеки Redux в наш проект.



5.8.4 Интеграция с библиотекой Redux



5.8.4.1 Библиотека Immutable.js


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

Мы будем использовать иммутабельность в наших состояниях и редюсерах. В самой библиотеке Immutable есть такой замечательный класс под названием Record. Нам он очень удобен потому, что по своему поведению довольно похож на обычный объект JS. Давайте рассмотрим его API:


Код
    
  const { Record } = require('immutable')
  const ABRecord = Record({ a: 1, b: 2 })
  const myRecord = ABRecord({ b: 3 })
  

Вызов функции Record() создает новую фабрику. Фабрика тоже является функцией, и её вызов создаёт экземпляры Record. Всё очень просто.

Объект Record всегда имеет значение для ключей, которые он определяет. Удаление ключа из объекта просто сбрасывает его к значению по умолчанию для этого ключа.


Код
    
  myRecord.size // 2
  myRecord.get('a') // 1
  myRecord.get('b') // 3
  const myRecordWithoutB = myRecord.remove('b')
  myRecordWithoutB.get('b') // 2
  myRecordWithoutB.size // 2
  

Значения, предоставленные конструктору при создании объекта, но не найденные в определённом Record-типе, будут игнорироваться.


Код
    
  const myRecord = ABRecord({ b: 3, x: 10 })
  myRecord.get('x') // undefined
  

Фабрике ABRecord предоставили ключ "x", хотя для типа создаваемого объекта мы определили только ключи "a" и "b". Значение для "x" будет проигнорировано для этого объекта.

Чтобы получить доступ к значению ключа объекта Record используется метод get():


Код
    
  get<K>(key: K, notSetValue?: any): TProps[K]
  get<T>(key: string, notSetValue: T): T
  

Если запрошенный ключ не определен Record-типом, будет возвращен notSetValue, если он указан. Обратите внимание, что этот сценарий приведет к ошибке при использовании Flow или TypeScript.

Чтобы узнать, содержит ли объект определённый ключ, используется метод has():


Код
    
 has(key: string): boolean
  

Чтобы установить значение ключа, используется метод set():


Код
    
 set<K>(key: K, value: TProps[K]): this
  

Выше мы рассмотрели работу с простым объектом, не содержащим вложенности. Однако библиотека отлично справляется и с многоуровневыми объектами:


Код
    
  const ABCDEF = Record({ a: 1, b: Record({ c: Record({ d: 2, e: 3 })(), f: 4 })() })
  // const ABCDEF = Record({ a: 1, b: new Record({ c: new Record({ d: 2, e: 3 }), f: 4 })() })

  let abcdef = ABCDEF()
  // abcdRecord = ABCDRecord({ a: 5, b: { c: { d: 6, e: 7 }, f: 8 } }) - задаём начальные значения

  // доступ к значению
  abcdef.get('a') // 1
  abcdef.getIn(['a']) // 1
  abcdef.getIn(['b']) // { c: { d: 2, e: 3 }:Record, f: 4 }:Record
  abcdef.getIn(['b', 'c', 'd']) // 2

  // установка значения
  abcdef.set('a', 10)
  // { a: 10, b: { c: { d: 2, e: 3 }:Record, f: 4 }:Record }:Record

  abcdef.setIn(['a'], 10)
  // { a: 10, b: { c: { d: 2, e: 3 }:Record, f: 4 }:Record }:Record

  abcdef.setIn(['b', 'c', 'd'], 11)
  // { a: 10, b: { c: { d: 11, e: 3 }:Record, f: 4 }:Record  }:Record

  // неглубокий мерж. Осторожно!

  // мержим весь объект
  abcdef.merge({a: 10, b: 12})
  // { a: 10, b: 12 }:Record

  abcdef.merge({a: 10, b: { f: 12 }})
  // { a: 10, b: { f: 12 }:Record }:Record

  // мержим определённую часть объекта
  abcdef.mergeIn([], {a: 10, b: { f: 12 }})
  // { a: 10, b: { f: 12 }:Record }:Record

  abcdef.mergeIn(['b'], { f: 12 })
  // { a: 1, b: { c: { d: 2, e: 3 }:Record, f: 12 }:Record }:Record

  abcdef.mergeIn(['b', 'c'], { d: 15 })
  // { a: 1, b: { c: { d: 15, e: 3 }:Record, f: 4 }:Record }:Record

  abcdef.mergeIn(['b'], { c: { d: 15 } })
  // { a: 1, b: { c: { d: 15 }:Record, f: 4 }:Record }:Record ключа 'e' нету!

  // глубокий мерж

  // мержим весь объект
  abcdef.mergeDeep({a: 10, b: 12})
  // { a: 10, b: 12 }:Record

  abcdef.mergeDeep({a: 10, b: { f: 12 }})
  // { a: 10, b: { c: { d: 2, e: 3 }:Record, f: 12 }:Record }:Record

  // мержим определённую часть объекта
  abcdef.mergeDeepIn([], {a: 10, b: { f: 12 }})
  //  { a: 10, b: { c: { d: 2, e: 3 }:Record, f: 12 }:Record }:Record

  abcdef.mergeDeepIn(['b'], { f: 12 })
  //  { a: 1, b: { c: { d: 2, e: 3 }:Record, f: 12 }:Record }:Record

  abcdef.mergeDeepIn(['b', 'c'], { d: 15 })
  //  { a: 1, b: { c: { d: 15, e: 3 }:Record, f: 4 }:Record }:Record

  abcdef.mergeDeepIn(['b'], { c: { d: 15 } })
  // { a: 1, b: { c: { d: 15, e: 3 }:Record, f: 4 }:Record }:Record
  

Вы можете вдоволь поиграться с библиотекой прямо в консоли на её сайте:




Что ж, знакомство с иммутабельностью и классом Record подошло к концу. Следующим шагом будет создание всех необходимых компонентов Redux.


5.8.4.2 Действия, редюсер и состояние списка


Ранее мы определили структуру объекта состояния приложения. Теперь нам необходимо создать соответствующие действия, редюсеры и состояние. Для них лучше всего определить отдельную папку, назовём её /redux. Название папки может быть любым, но поскольку мы будем хранить генераторы действий, редюсер и состояние в одном месте, то название должно быть совокупным. Поэтому имя /redux, на мой взгляд самое подходящее. Структура этой папки будет соответствовать структуре объекта состояния, это обеспечит понятность и простоту ориентирования.

Давайте создадим файлы генераторов действий, редюсера и состояния для нашего списка приёмов:




Здесь файлы названы полным именем: что-тоActions.js. Мы могли бы назвать просто actions.js. Но тогда это усложнило бы поиск файла в среде разработки. Поэтому полное имя лучше оставить.

Что ж, самое время создать необходимые генераторы действий в файле appointmentListActions.js:


Код
    
  import service from '../../../services/AppointmentService'

  // очистить список
  export function clean () {
      return { type: 'CLEAN_APPOINTMENT_LIST' }
  }

  // очистить ошибку
  export function cleanError () {
      return { type: 'CLEAN_APPOINTMENT_LIST_ERROR' }
  }

  // очистить фильтр
  export function cleanFilter () {
      return { type: 'CLEAN_APPOINTMENT_LIST_FILTER' }
  }

  // применить фильтр: сразу несколько полей в объекте changes
  export function changeFilter (changes, shouldReload) {
      return {
          type: 'CHANGE_APPOINTMENT_LIST_FILTER',
          payload: { changes, shouldReload }
      }
  }

  // применить фильтр: только одно поле
  export function changeFilterField (name, value, shouldReload) {
      return {
          type: 'CHANGE_APPOINTMENT_LIST_FILTER_FIELD',
          payload: { name, value, shouldReload }
      }
  }

  // загрузить данные для списка с сервера
  export function load (params) {
      return dispatch => {
          dispatch({ type: 'LOAD_APPOINTMENT_LIST_REQUEST' })

          return service.find(params).then(response => {
              dispatch({
                type: 'LOAD_APPOINTMENT_LIST_SUCCESS',
                payload: response.data
              })

              return response
          }).catch(error => {
              dispatch({ type: 'LOAD_APPOINTMENT_LIST_FAILURE', payload: error })
          })
      }
  }
  

Как вы уже читали ранее, действие в Redux представлено простым JS объектом. В этом файле мы определили функции, которые будут создавать и возвращать такие объекты при вызове. Они называются генераторами действий.

Особого внимания заслуживает генератор load(). Как вы уже читали в документации Redux, для подгрузки данных с сервера нужен мидлвар thunkMiddleware. Далее мы подключим его, а пока представим, что он уже подключён. Метод интересен тем, что он является асинхронным генератором действия. Как только он был вызван, сначала генерируется действие dispatch({ type: 'LOAD_APPOINTMENT_LIST_REQUEST' }) с помощью функции dispatch(). Затем мы вызываем наш сервис и ожидаем ответа от сервера. В случае успешной обработки запроса мы генерируем действие типа 'LOAD_APPOINTMENT_LIST_SUCCESS', а если произошли какие-то ошибки - действие типа 'LOAD_APPOINTMENT_LIST_FAILURE'.

Если вы обратили внимание, я использовал строковые литералы. Они удобны, пока проект мал. В нашем случае лучше перейти на строковые константы. Для этой цели создадим файл Constants.js в папке /lib и определим в нём все необходимые типы действий в виде констант:


Код
    
  import keyMirror from 'key-mirror'

  export const ACTION_TYPES = keyMirror({
       CLEAN_APPOINTMENT_LIST_ERROR: null,
       CLEAN_APPOINTMENT_LIST: null,
       CLEAN_APPOINTMENT_LIST_FILTER: null,
       CHANGE_APPOINTMENT_LIST_FILTER: null,
       CHANGE_APPOINTMENT_LIST_FILTER_FIELD: null,
       LOAD_APPOINTMENT_LIST_REQUEST: null,
       LOAD_APPOINTMENT_LIST_SUCCESS: null,
       LOAD_APPOINTMENT_LIST_FAILURE: null
  })
  

Здесь используется библиотека key-mirror. Она очень удобна, так как позволяет сократить код. Сравните:


Код
    
  export const ACTION_TYPES = {
     CLEAN_APPOINTMENT_LIST_ERROR: 'CLEAN_APPOINTMENT_LIST_ERROR',
     CLEAN_APPOINTMENT_LIST: 'CLEAN_APPOINTMENT_LIST',
     CLEAN_APPOINTMENT_LIST_FILTER: 'CLEAN_APPOINTMENT_LIST_FILTER',
     CHANGE_APPOINTMENT_LIST_FILTER: 'CHANGE_APPOINTMENT_LIST_FILTER',
     CHANGE_APPOINTMENT_LIST_FILTER_FIELD: 'CHANGE_APPOINTMENT_LIST_FILTER_FIELD',
     LOAD_APPOINTMENT_LIST_REQUEST: 'LOAD_APPOINTMENT_LIST_REQUEST',
     LOAD_APPOINTMENT_LIST_SUCCESS: 'LOAD_APPOINTMENT_LIST_SUCCESS',
     LOAD_APPOINTMENT_LIST_FAILURE: 'LOAD_APPOINTMENT_LIST_FAILURE'
  }
  

Итоговый код файла генераторов действий appointmentListActions.js будет таким:


Код
    
  import { ACTION_TYPES } from '../../../lib/Constants'

  import service from '../../../services/AppointmentService'

  const {
      CLEAN_APPOINTMENT_LIST_ERROR,

      CLEAN_APPOINTMENT_LIST,

      CLEAN_APPOINTMENT_LIST_FILTER,
      CHANGE_APPOINTMENT_LIST_FILTER,
      CHANGE_APPOINTMENT_LIST_FILTER_FIELD,

      LOAD_APPOINTMENT_LIST_REQUEST,
      LOAD_APPOINTMENT_LIST_SUCCESS,
      LOAD_APPOINTMENT_LIST_FAILURE
  } = ACTION_TYPES

  // очистить список
  export function clean () {
      return { type: CLEAN_APPOINTMENT_LIST }
  }

  // очистить ошибку
  export function cleanError () {
      return { type: CLEAN_APPOINTMENT_LIST_ERROR }
  }

  // очистить фильтр
  export function cleanFilter () {
      return { type: CLEAN_APPOINTMENT_LIST_FILTER }
  }

  // применить фильтр: сразу несколько полей в объекте changes
  export function changeFilter (changes, shouldReload) {
      return {
          type: CHANGE_APPOINTMENT_LIST_FILTER,
          payload: { changes, shouldReload }
      }
  }

  // применить фильтр: только одно поле
  export function changeFilterField (name, value, shouldReload) {
      return {
          type: CHANGE_APPOINTMENT_LIST_FILTER_FIELD,
          payload: { name, value, shouldReload }
      }
  }

  // загрузить данные для списка с сервера
  export function load (params) {
      return dispatch => {
          dispatch({ type: LOAD_APPOINTMENT_LIST_REQUEST })

          return service.find(params).then(response => {
              dispatch({
                type: LOAD_APPOINTMENT_LIST_SUCCESS,
                payload: response.data
              })

              return response
          }).catch(error => {
              dispatch({ type: LOAD_APPOINTMENT_LIST_FAILURE, payload: error })
          })
      }
  }
  

Теперь зададим состояние списка в файле AppointmentListInitialState.js:


Код
    
  const { Record } = require('immutable')

  export default Record({
      error: null,
      isFetching: false,
      shouldReload: false,
      dataSource: Record({
          data: [],
          filter: Record({
              startDate: null,
              endDate: null,
              clientName: '',
              onlyMe: false
          })(),
          // пагинация
          pagination: Record({
              page: 1, // текущий номер страницы
              size: 15, // размер страницы
              totalCount: 0 // всего элементов
          })()
          // ...
      })()
  })
  

Эта структура нам хорошо знакома, мы лишь применили библиотеку Immutable.js.

Остался последний элемент - редюсер. Определим его в файле appointmentListReducer.js:


Код
    
  import Immutable from 'immutable'

  import InitialState from './AppointmentListInitialState'

  import { ACTION_TYPES } from '../../../lib/Constants'

  const {
      CLEAN_APPOINTMENT_LIST_ERROR,

      CLEAN_APPOINTMENT_LIST,

      CLEAN_APPOINTMENT_LIST_FILTER,
      CHANGE_APPOINTMENT_LIST_FILTER,
      CHANGE_APPOINTMENT_LIST_FILTER_FIELD,

      LOAD_APPOINTMENT_LIST_REQUEST,
      LOAD_APPOINTMENT_LIST_SUCCESS,
      LOAD_APPOINTMENT_LIST_FAILURE
  } = ACTION_TYPES

  const initialState = new InitialState()

  export default function (state = initialState, action) {

      // важный код! state может быть undefined
      if (!(state instanceof InitialState)) {
          return initialState.mergeDeep(state)
      }

      switch (action.type) {

          // сбрасываем все значения
          case CLEAN_APPOINTMENT_LIST:
              return state.clear()
                  .setIn(['shouldReload'], action.payload || false)

          // сбрасываем ошибку
          case CLEAN_APPOINTMENT_LIST_ERROR:
              return state.removeIn(['error'])

          // сбрасываем все значения фильтра
          case CLEAN_APPOINTMENT_LIST_FILTER:
              return state.getIn(['dataSource', 'f']).clear()
                          .setIn(['shouldReload'], true)

          // применяем множество значений фильтра
          case CHANGE_APPOINTMENT_LIST_FILTER: {
              const { changes, shouldReload } = action.payload

              if (changes) {
                  return state
                      .mergeIn(['dataSource', 'filter'], changes)
                      .setIn(['shouldReload'], shouldReload)
              }

              break
          }

          // изменяем одно значение фильтра
          case CHANGE_APPOINTMENT_LIST_FILTER_FIELD: {
              const { name, value, shouldReload = true } = action.payload

              return state
                  .setIn(['dataSource', 'filter', name], value)
                  .setIn(['shouldReload'], shouldReload)
          }

          // сигнализируем о том, что началась загрузка данных списка с сервера
          case LOAD_APPOINTMENT_LIST_REQUEST:
              return state
              .setIn(['error'], null)
              .setIn(['shouldReload'], false)
              .setIn(['isFetching'], true)

          // сохраняем данные, пришедшие с сервера
          case LOAD_APPOINTMENT_LIST_SUCCESS: {
              const {
                  data
              } = action.payload

              return state
                  .setIn(['isFetching'], false)
                  .setIn(['shouldReload'], false)
                  .setIn(['dataSource', 'data'], action.payload)
          }

          // сохраняем ошибку
          case LOAD_APPOINTMENT_LIST_FAILURE:
              return state
                  .setIn(['isFetching'], false)
                  .setIn(['shouldReload'], false)
                  .setIn(['error'], action.payload)
      }

      return state
  }
  

Код довольно прост: мы возвращаем обновлённое состояние списка с изменившимися значениями в ответ на соответствующие действия.

Обратите внимание на код:


Код
    
   // важный код! state может быть undefined
   if (!(state instanceof InitialState)) {
       return initialState.mergeDeep(state)
   }
  

Он является необходимым, так как Redux вызовет наш редюсер в первый раз со значением state === undefined. Это как раз то место, где мы возвращаем начальное состояние приложения. Подробно об этом написано здесь.

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




Исходный код AppointmentInitialState.js:


Код
    
  import List from './list/AppointmentListInitialState'

  const { Record } = require('immutable')

  export default Record({
     list: List()
  })
  

Исходный код appointmentReducer.js:


Код
    
  import InitialState from './AppointmentInitialState'

  import listReducer from './list/appointmentListReducer'

  const initialState = new InitialState()

  export default function (state = initialState, action) {
     let nextState = state

     const list = listReducer(state.list, action)
     if (list !== state.list) nextState = nextState.setIn(['list'], list)

     return nextState
  }
  

Код промежуточного уровня состояния очень простой. А вот редюсер уже поинтереснее. В нём мы применяем редюсер списка к соответствующей части состояния state.list. Если состояние списка изменилось, изменяем соответствующую часть текущего состояния: nextState.setIn(['list'], list).

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

Вы можете отказаться от предложенной структуры объекта состояния и папки /redux, разработав что-то своё. Но сделать это лучше в самом начале, так как изменить уже используемую структуру крайне сложно и чревато крэшами приложения.

Итак, мы подобрались к корневому редюсеру. Назовём его rootReducer и поместим в файл rootReducer.js:




Вот его исходный код:


Код
    
  import { combineReducers } from 'redux'
  import { connectRouter } from 'connected-react-router'

  import appointment from './appointment/appointmentReducer'

  const rootReducer = (history) => combineReducers({
     router: connectRouter(history),
     appointment
  })

  export default rootReducer
  

Папка /redux полностью готова. Следующим шагом будет подключение библиотеки к проекту.


5.8.4.3 Подключение Redux к проекту


Первое, с чего мы начнём - добавим все необходимые зависимости в файл package.json:




Подробнее о подключаемых модулях вы можете почитать в документации.

Далее нам необходимо модифицировать файл index.js:


Код
    
  import React from 'react'
  import ReactDOM from 'react-dom'

  import {Provider} from 'react-redux'
  import thunkMiddleware from 'redux-thunk'
  import {applyMiddleware, compose, createStore} from 'redux'
  import {routerMiddleware, ConnectedRouter} from 'connected-react-router'

  import {createBrowserHistory} from 'history'

  import './index.scss'

  import App from './App'

  import rootReducer from './redux/rootReducer'

  import AppointmentInitialState from './redux/appointment/AppointmentInitialState'

  function getInitialState () {
      return {
          appointment: AppointmentInitialState()
      }
  }

  // создаём кастомную историю
  const history = createBrowserHistory()

  const store = createStore(
      rootReducer(history),
      getInitialState(),
      compose(applyMiddleware(routerMiddleware(history), thunkMiddleware))
  )

  ReactDOM.render((
      <Provider store={store}>
          <ConnectedRouter history={history}>
              <App history={history} />
          </ConnectedRouter>
      </Provider>
    ), document.getElementById('root')
  );
  

Проанализируем изменения:

  • Метод getInitialState задаёт начальное состояние приложения.

  • Вызов createStore создаёт хранилище состояния приложения. К тому же здесь мы применяем необходимые мидлвары: thunkMiddleware, routerMiddleware и applyMiddleware.

  • Компонент <Provider> принимает хранилище состояния в качастве свойства.

  • Компонент <ConnectedRouter> обеспечивает совместную работу библиотек Router и Redux.

Далее на очереди App.js:


Код
    
  import React, { Component } from 'react';

  import {
    Route,
    Switch,
    Redirect,
    withRouter
  } from "react-router-dom"

  import { connect } from 'react-redux'
  import { ConnectedRouter } from 'connected-react-router'

  import './App.scss';

  import Home from './containers/Home/Home'
  import Appointments from './containers/Appointments/Appointments'

  class App extends Component {
    render() {
      const { history } = this.props

      return (
        <ConnectedRouter history={history}>
          <div className="App">
            <Switch>
              <Route path='/home' component={Home} />
              <Route path='/appointments' component={Appointments} />
              <Redirect from='/' to='/home'/>
            </Switch>
          </div>
        </ConnectedRouter>
      );
    }
  }

  export default withRouter(connect()(App))
  

На этом подключение Redux к нашему приложению завершено. Здесь вы можете увидеть, что компоненты <Home> и <Appointments> находятся уже в папке /containers. Сейчас мы разберёмся почему это так.


5.8.4.4 Преобразование компонентов в контейнеры


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

В нашем приложении кандидатами в контейнеры являются компоненты <Home>, <Appointments> и <Header>. Давайте переместим их в папку /containers:




Исходный код компонентов также необходимо модифицировать. Начнём с компонента <Header>:


Код
    
  import React, { Component } from 'react'

  import cn from 'classnames'

  import {connect} from 'react-redux'

  import './Header.scss'

  function mapStateToProps (state) {
      return state
  }

  class Header extends Component {

    render () {
      const {
        title,
        userName,
        className,
        bodyClassName,
        renderIcon
      } = this.props

      return (
        <div className={cn('Header', className)}>
          <div className={cn('Header-Body', bodyClassName)}>
            <div className='flex-1 d-flex flex-row justify-content-start align-items-center'>
              {renderIcon && renderIcon()}
              <div className='Header-Title'>
                {title}
              </div>
            </div>
            <div className='flex-1 d-flex flex-row justify-content-end align-items-center'>
              {userName && (
                <div className='Header-UserName'>
                  {userName}
                </div>
              )}
              <a className='btn btn-primary Header-ExitBtn'>
                Выйти
              </a>
            </div>
          </div>
        </div>
      )
    }
  }

  export default connect(null, null)(Header)
  

Здесь немного изменений, но в этом контейнере есть нереализованный функционал: отображение текущего пользователя и кнопка "Выйти". Поэтому на данный момент контейнер не использует состояние и действия, а метод connect(null, null) принимает два аргумента null. Реализация функционала регистрации и логина будет вашим домашним заданием.

Теперь следует преобразовать компонент <Home>:


Код
    
  import React, { Component } from 'react'

  import { map } from 'underscore'
  import { Link } from "react-router-dom"

  import {connect} from 'react-redux'

  import './Home.scss'

  import Header from '../Header/Header'

  import { ReactComponent as User } from '../../images/user.svg'
  import { ReactComponent as Star } from '../../images/star.svg'
  import { ReactComponent as Nurse } from '../../images/nurse.svg'
  import { ReactComponent as House } from '../../images/house.svg'
  import { ReactComponent as Clients } from '../../images/clients.svg'
  import { ReactComponent as Messages } from '../../images/messages.svg'
  import { ReactComponent as Broadcast } from '../../images/broadcast.svg'
  import { ReactComponent as Employees } from '../../images/employees.svg'
  import { ReactComponent as Appointment } from '../../images/appointment.svg'

  const TITLE = 'Домашняя'

  const SECTIONS = [
    { title: 'Приёмы', href: '/appointments', Icon: Appointment },
    { title: 'События', href: '/events', Icon: Star  },
    { title: 'Оповещения', href: '/notifications', Icon: Broadcast },
    { title: 'Сообщения', href: '/messages', Icon: Messages },
    { title: 'Клиенты', href: '/clients', Icon: Clients },
    { title: 'Сотрудники', href: '/employees', Icon: Employees }
  ]

  function mapStateToProps (state) {
      return state
  }

  class Home extends Component {

    render () {
      return (
        <div className='Home'>
          <Header
                  title={TITLE}
                  userName='Иванов Иван Иванович'
                  className='Home-Header'
                  renderIcon={() => (
              <House className='Header-Icon'/>
            )}
          />
          <div className='Home-Body'>
            <div className='SectionNavigation'>
              {map(SECTIONS, ({ title, href, Icon }) => (
                // с помощью компонента Link будет осуществляться
                // навигация по разделам приложения
                <Link className='SectionNavigation-Item Section' to={href}>
                  <Icon className='Section-Icon'/>
                  <span className='Section-Title'>{title}</span>
              </Link>
              ))}
            </div>
          </div>
        </div>
      )
    }
  }

  export default connect(null, null)(Home)
  

Изменения такие же, как и в файле выше. Но напомню, наш пример очень простой. По мере роста функционала, почти каждый контейнер использует состояние и действия.

А теперь перейдём к самому интересному. Преобразуем компонент <Appointments>:


Код
    
  import React, { Component } from 'react'
  
  import {connect} from 'react-redux'
  import {bindActionCreators} from 'redux'
  
  import Moment from 'react-moment'
  import { map, filter } from 'underscore'
  import { Form, Button } from 'reactstrap'
  
  import Table from '../../components/Table/Table'
  import TextField from '../../components/Form/TextField/TextField'
  import DateField from '../../components/Form/DateField/DateField'
  import CheckboxField from '../../components/Form/CheckboxField/CheckboxField'
  
  import './Appointments.scss'
  
  import Header from '../Header/Header'
  
  import * as appointmentListActions from '../../redux/appointment/list/appointmentListActions'
  
  import { ReactComponent as Search } from '../../images/search.svg'
  import { ReactComponent as Appointment } from '../../images/appointment.svg'
  
  const TITLE = 'Приёмы'
  
  const USER = 'Иванов Иван Иванович'
  
  // маппинг состояния приложения в свойства компонента-контейнера
  function mapStateToProps (state) {
      return {
          error: state.appointment.list.error,
          isFetching: state.appointment.list.isFetching,
          dataSource: state.appointment.list.dataSource,
          shouldReload: state.appointment.list.shouldReload
      }
  }
  
  // подключение генераторов действий к компоненту-контейнеру
  function mapDispatchToProps(dispatch) {
      return {
          actions: {
              ...bindActionCreators(appointmentListActions, dispatch)
          }
      }
  }
  
  class Appointments extends Component {
  
    componentDidMount() {
      this.load()
    }
  
    onChangeFilterField = (name, value) => {
      this.changeFilterField(name, value)
    }
  
    onChangeFilterDateField = (name, value) => {
      this.changeFilterField(name, value && value.getTime())
    }
  
    onSearch = () => {
      this.load()
    }
  
    load() {
      const {
        actions,
        dataSource: ds
      } = this.props
  
      actions.load({
          ...ds.filter.toJS()
      })
    }
  
    changeFilterField (name, value, shouldReload) {
          this.props
              .actions
              .changeFilterField(name, value, shouldReload)
    }
  
    render() {
  
      // берём данные из состояния приложения используя свойства props
      const {
        isFetching,
        dataSource: ds
      } = this.props
  
      const {
        startDate,
        endDate,
        clientName,
        onlyMe
      } = ds.filter
  
      return (
        <div className='Appointments'>
          <Header
                  title={TITLE}
                  userName={USER}
                  className='Appointments-Header'
                  bodyClassName='Appointments-HeaderBody'
                  renderIcon={() => (
              <Appointment className='Header-Icon' />
            )}
          />
          <div className='Appointments-Body'>
            <div className='Appointments-Filter'>
              <Form className='Appointments-FilterForm'>
                <DateField
                        hasTime
                        name='startDate'
                        value={startDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='С'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <DateField
                        hasTime
                        name='endDate'
                        value={endDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='По'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <TextField
                        name='clientName'
                        value={clientName}
                        placeholder='Клиент'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <CheckboxField
                        name='onlyMe'
                        label='Только я'
                        value={onlyMe}
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <Button
                        className='Appointments-SearchBtn'
                        onClick={this.onSearch}>
                  <Search className='Appointments-SearchBtnIcon'/>
                </Button>
              </Form>
            </div>
            <Table
                    data={ds.data}
                    isLoading={isFetching}
                    className='AppointmentList'
                    columns={[
                    {
                    dataField: 'date',
                    text: 'Дата',
                    headerStyle: {
                      width: '150px'
                    },
                    formatter: (v, row) => {
                      return (
                        <Moment date={v} format='DD.MM.YYYY HH.mm' />
                      )
                    }
                  },
                  {
                    dataField: 'clientName',
                    text: 'Клиент',
                    headerStyle: {
                      width: '300px'
                    }
                  },
                  {
                    dataField: 'status',
                    text: 'Статус'
                  },
                  {
                    dataField: 'holderName',
                    text: 'Принимающий',
                    headerStyle: {
                      width: '300px'
                    }
                  },
                  {
                    dataField: 'compliences',
                    text: 'Жалобы',
                    headerStyle: {
                      width: '200px'
                    }
                  },
                  {
                    dataField: 'diagnosis',
                    text: 'Диагноз',
                    headerStyle: {
                      width: '200px'
                    }
                  }
                ]}
              />
          </div>
        </div>
      )
    }
  }
  
  // объявляем контейнер
  export default connect(mapStateToProps, mapDispatchToProps)(Appointments)
  

Проанализируем код:

  • Маппер mapStateToProps осуществляет отображение необходимой части состояния в свойства контейнера. Маппер будет вызван каждый раз, когда состояние приложения изменилось. Каждый раз, когда состояние приложения изменяется, будет вызван метод render() контейнера. Нам необязательно передавать в контейнер всё состояние приложения. Обычно нам нужна лишь какая-то определённая его часть, то есть только те данные, которые контейнер использует. Для этого и нужен метод mapStateToProps.

  • Маппер mapDispatchToProps осуществляет связывание контейнера с генераторами действий. Это даёт возможность делать вызов генератора действия в контейнере с помощью выражения this.props.actions.load(). После такого вызова будет создано действие, которое будет передано в редюсеры. Нужный редюсер опознает действие и вернет обновлённое состояние приложения. С этого момента состояние приложения изменилось, что повлечёт сначала вызов метода mapStateToProps, а затем метода render() контейнера.

  • Состояние приложения доступно через this.props.

Ранее мы вручную указывали начало и конец процесса подгрузки данных с сервера, вызывая this.setState({ isLoading: true }) и this.setState({ isLoading: false }). В данном варианте кода мы заменили локальное состояние компонента this.state на состояние Redux. Теперь состояние подгрузки данных определяется с помощью нового флага isFetching, который изменяется с помощью действий. Это даёт возможность не засорять код контейнера вызовами this.setState({ isLoading: ... }), тем самым упрощая его!

Вместо вызова метода load() сервиса AppointmentService напрямую из контейнера, мы теперь используем this.props.actions.load(). А если нам нужны какие-либо действия сразу после успешной загрузки данных, мы можем использовать метод then():


Код
    
  this.props.actions.load().then(response => {
      // выполняем необходимые действия
  })
  

Что ж, на этом всё! Теперь все необходимые преобразования компонентов выполнены. А чтобы окончательно разобраться во всех описанных выше деталях, вам следует поэкспериментировать с полноценным кодом примера:




Наверняка у вас мог возникнуть вопрос: когда компонент следует преобразовать в контейнер? Здесь я бы выделил два доминирующих признака, явно указывающих на то, что компонент следует преобразовать в контейнер:

  1. Компонент осуществляет загрузку(download) данных с сервера и/или подгрузку(upload) данных на сервер. Очевидно, что для этого ему понадобятся действия. В случае нашего приложения ярким примером является компонент-контейнер <Appointments>, а в общем случае это обычно:

    • Компоненты, содержащие списки(таблицы) данных

    • Формы

    • Компоненты (страницы или модальные окна), отображающие детали какой-либо сущности, например детали пациента.

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

Итак, я надеюсь, вы получили достаточно практической информации по использованию замечательной библиотеки Redux. Она способна серьёзно улучшить структуру вашего проекта, а также ощутимо упростить код компонентов, что естественно снижает вероятность ошибок. Кроме того, библиотека даёт массу полезных возможностей компонентам-контейнерам, таких как доступ к объекту истории и текущей локации(роутинг) через props, отслеживание состояния приложения, согласованное выполнение действий. Внедряя Redux в проект вы избегаете решения массы типовых проблем управления состоянием - это уже сделали за вас!

Есть ещё одна хорошая новость: Redux можно внедрять и в нативное JS приложение, так как Redux-у не обязательно нужен именно React. Подключение не составляет никаких сложностей. Здесь можно посмотреть небольшой пример.

На этом мы заканчиваем знакомство с состоянием приложения. Остаётся лишь пожелать вам успехов в приобретении практического опыта. Не поленитесь и изучите Redux как можно глубже! Поверьте, потраченное время с лихвой окупится понятным, масштабируемым кодом, лишенным досадных архитектурных ошибок.



5.8.5 Пути рефакторинга



5.8.5.1 Справочные данные


В нашем приложении на текущий момент с сервера загружается только список приёмов. Как правило на подобной странице с сервера загружается гораздо больше данных. Вспомним, что приём может иметь несколько статусов, например: "Завершён", "Активен", "Пропущен" и пр. Было бы удобно фильтровать приёмы по статусу, например показывать только завершённые приёмы. Давайте осуществим загрузку этих статусов с сервера и посмотрим как изменится код нашего приложения.

Итак, в файле MockData.js добавим новые фейковые данные для статусов:


Код
    
  const appointmentStatuses = [
    { id: 0, title: 'Завершён' },
    { id: 1, title: 'Ожидается' },
    { id: 2, title: 'Пропущен' },
    { id: 3, title: 'Отменён' },
    { id: 4, title: 'Перенесён' },
    { id: 5, title: 'Активен' }
  ]

  export function getAppointmentStatuses () {
    return appointmentStatuses
  }
  

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

Обратите внимание как справочные данные контрастируют с нашим списком приёмов. Размер списка приёмов растёт изо дня в день, поэтому без пагинации, фильтрации и сортировки работать с ним попросту невозможно. А вот со списком статусов, являющимся справочными данными, ничего подобного не происходит. Он обычно неизменен, данных немного - мы даже без труда можем видеть их все (хотя это необязательно). Сортировать, фильтровать или загружать по частям этот список нам не нужно.

Коль мы выделили подмножество справочных данных, логично будет организовать их загрузку неким специализированным сервисом, что избавит от кодовой нагрузки все остальные сервисы. Слово "справочник" на английский часто переводится как "directory". Это довольно знакомое и короткое слово, так что давайте возьмём его на вооружение и создадим специализированный сервис, который будет загружать с сервера только справочные данные.

Назовём этот сервис DirectoryService. Его код будет таким:


Код
    
  import BaseService from './BaseService'

  export class DirectoryService extends BaseService {
      findAppointmentStatuses () {
          return super.request({
              url: '/directory/appointment-statuses'
          })
      }
  }

  export default new DirectoryService()
  

У этого сервиса все пути будут начинаться с /directory. Типы запросов только GET. Число методов будет увеличиваться в соответствии с размером множества справочных данных.

То же самое будет характерно и для фейкового сервера. Создадим контроллер DirectoryController:


Код
    
  import * as mock from '../MockData'
  import BaseController from './BaseController'

  class DirectoryController extends BaseController {
      getPath () {
          return '/directory'
      }

      getHandlers () {
          return [
              {
                  path: '/appointment-statuses',
                  handler: (vars, params) => {
                      return mock.getAppointmentStatuses()
                  }
              }
          ]
      }
  }

  export default new DirectoryController ()
  

Теперь подключим его в классе MockServer:


Код
    
  import directoryController from './controllers/DirectoryController'
  import appointmentController from './controllers/AppointmentController'

  const ROUTING = {
     [directoryController.getPath()]: directoryController,
     [appointmentController.getPath()]: appointmentController
  }
  

Разумеется, справочные данные следует отразить и в структуре redux. Выделим соответствующее поддерево directory, создав одноимённую папку:




Код файлов appointmentStatusListActions.js AppointmentStatusListInitialState.js и appointmentStatusListReducer.js вам будет уже знаком.

Файл appointmentStatusListActions.js:


Код
    
  import { ACTION_TYPES } from '../../../../../lib/Constants'

  import service from '../../../../../services/DirectoryService'

  const {
      CLEAR_APPOINTMENT_STATUS_LIST,
      CLEAR_APPOINTMENT_STATUS_LIST_ERROR,
      LOAD_APPOINTMENT_STATUS_LIST_REQUEST,
      LOAD_APPOINTMENT_STATUS_LIST_SUCCESS,
      LOAD_APPOINTMENT_STATUS_LIST_FAILURE
  } = ACTION_TYPES

  export function clear () {
      return { type: CLEAR_APPOINTMENT_STATUS_LIST }
  }

  export function clearError () {
      return { type: CLEAR_APPOINTMENT_STATUS_LIST_ERROR }
  }

  export function load () {
      return dispatch => {
          dispatch({ type: LOAD_APPOINTMENT_STATUS_LIST_REQUEST })
          return service.findAppointmentStatuses().then(response => {
              dispatch({
                  type: LOAD_APPOINTMENT_STATUS_LIST_SUCCESS,
                  payload: { data: response.data }
              })

              return response
          }).catch(e => {
              dispatch({ type: LOAD_APPOINTMENT_STATUS_LIST_FAILURE, payload: e })
          })
      }
  }
  

Файл AppointmentStatusListInitialState.js:


Код
    
  const { Record } = require('immutable')

  export default Record({
      error: null,
      shouldReload: false,
      dataSource: Record({
          data: []
      })()
  })
  

Файл appointmentStatusListReducer.js:


Код
    
  import InitialState from './AppointmentStatusListInitialState'

  import { ACTION_TYPES } from '../../../../../lib/Constants'

  const {
      CLEAR_APPOINTMENT_STATUS_LIST,
      CLEAR_APPOINTMENT_STATUS_LIST_ERROR,
      LOAD_APPOINTMENT_STATUS_LIST_REQUEST,
      LOAD_APPOINTMENT_STATUS_LIST_SUCCESS,
      LOAD_APPOINTMENT_STATUS_LIST_FAILURE
  } = ACTION_TYPES

  const initialState = new InitialState()

  export default function appointmentStatusListReducer (state = initialState, action) {
      if (!(state instanceof InitialState)) {
          return initialState.mergeDeep(state)
      }

      switch (action.type) {
          case CLEAR_APPOINTMENT_STATUS_LIST:
              return state.clear()

          case CLEAR_APPOINTMENT_STATUS_LIST_ERROR:
              return state.removeIn(['error'])

          case LOAD_APPOINTMENT_STATUS_LIST_REQUEST:
              return state.removeIn(['error'])
                          .setIn(['isFetching'], true)

          case LOAD_APPOINTMENT_STATUS_LIST_SUCCESS: {
              const { data } = action.payload

              return state
                  .setIn(['isFetching'], false)
                  .setIn(['dataSource','data'], data)
          }

          case LOAD_APPOINTMENT_STATUS_LIST_FAILURE:
              return state
                  .setIn(['isFetching'], false)
                  .setIn(['error'], action.payload)
      }

      return state
  }
  

Код остальных redux-файлов такой же, как и для списка приёмов, вы посмотрите его немного позже в проекте приложения.

Чтобы было удобно фильтровать приёмы по статусу из списка, нам очень подойдёт компонент-селектор. В библиотеке reactstrap такой уже есть.

Давайте создадим простой компонент-декоратор <SelectField>:


Код
    
  import React, { Component } from 'react'

  import { map } from 'underscore'

  import cn from 'classnames'
  import PropTypes from 'prop-types'
  import {Label, Input, FormGroup} from 'reactstrap'

  import './SelectField.scss'

  /**
   * options = [{ value, text }]
   */

  export default class SelectField extends Component {

      static propTypes = {
          name: PropTypes.string,
          label: PropTypes.string,
          value: PropTypes.string,
          options: PropTypes.array,
          isMultiple: PropTypes.bool,
          className: PropTypes.string,
          placeholder: PropTypes.string,
          onChange: PropTypes.func
      }

      static defaultProps = {
          type: 'text',
          value: '',
          isMultiple: false,
          onChange: function () {}
      }

      onChange = e => {
          const value = +e.target.value
          const { name, onChange: cb } = this.props
          cb(name, value)
      }

      render () {
          const {
              type,
              name,
              label,
              value,
              options,
              className,
              placeholder
          } = this.props

          return (
              <FormGroup className={cn('SelectField', className)}>
                  {label ? (
                      <Label className='SelectField-Label'>
                        {label}
                      </Label>
                  ) : null}
                  <Input
                          name={name}
                          value={value}
                          type="select"
                          placeholder={placeholder}
                          className='SelectField-Input'
                          onChange={this.onChange}>
                      {map(options, o => <option value={o.value}>{o.text}</option>)}
                </Input>
              </FormGroup>
          )
      }
  }
  

По своему коду он почти копия компонента <TextField> с парой отличий, важнейшим из которых является свойство type="select". Оно то и делает наш компонент селектором.

Теперь нам надо изменить наш контейнер <Appointments>, добавив в фильтр новое поле-селектор статусов, а также код их загрузки:


Код
    
  import React, { Component } from 'react'
  
  import {connect} from 'react-redux'
  import {bindActionCreators} from 'redux'
  
  import Moment from 'react-moment'
  import { map, filter } from 'underscore'
  import { Form, Button } from 'reactstrap'
  
  import Table from '../../components/Table/Table'
  import TextField from '../../components/Form/TextField/TextField'
  import DateField from '../../components/Form/DateField/DateField'
  import SelectField from '../../components/Form/SelectField/SelectField'
  import CheckboxField from '../../components/Form/CheckboxField/CheckboxField'
  
  import './Appointments.scss'
  
  import Header from '../Header/Header'
  
  import * as appointmentListActions from '../../redux/appointment/list/appointmentListActions'
  import * as appointmentStatusListActions from '../../redux/directory/appointment/status/list/appointmentStatusListActions'
  
  import { ReactComponent as Search } from '../../images/search.svg'
  import { ReactComponent as Appointment } from '../../images/appointment.svg'
  
  const TITLE = 'Приёмы'
  
  const USER = 'Иванов Иван Иванович'
  
  // маппинг состояния приложения в свойства компонента-контейнера
  function mapStateToProps (state) {
      return {
          error: state.appointment.list.error,
          isFetching: state.appointment.list.isFetching,
          dataSource: state.appointment.list.dataSource,
          shouldReload: state.appointment.list.shouldReload,
  
          directory: state.directory
      }
  }
  
  // подключение генераторов действий к компоненту-контейнеру
  function mapDispatchToProps(dispatch) {
      return {
          actions: {
              ...bindActionCreators(appointmentListActions, dispatch),
              
              status: {
                list: bindActionCreators(appointmentStatusListActions, dispatch)
              }
          }
      }
  }
  
  class Appointments extends Component {
  
    componentDidMount() {
      this.load()
      this.loadStatuses()
    }
  
    onChangeFilterField = (name, value) => {
      this.changeFilterField(name, value)
    }
  
    onChangeFilterDateField = (name, value) => {
      this.changeFilterField(name, value && value.getTime())
    }
  
    onSearch = () => {
      this.load()
    }
  
    load() {
      const { 
        actions, 
        dataSource: ds 
      } = this.props
  
      actions.load({
          ...ds.filter.toJS()
      })
    }
  
    loadStatuses () {
      this.props.actions.status.list.load()
    }
  
    changeFilterField (name, value, shouldReload) {
      this.props
          .actions
          .changeFilterField(name, value, shouldReload)
    }
  
    render() {
  
      // берём данные из состояния приложения используя свойства props
      const {
        isFetching,
        dataSource: ds,
        directory
      } = this.props
  
      const {  
        startDate,
        endDate,
        clientName,
        statusId,
        onlyMe
      } = ds.filter
  
      return (
        <div className='Appointments'>
          <Header
                  title={TITLE}
                  userName={USER}
                  className='Appointments-Header'
                  bodyClassName='Appointments-HeaderBody'
                  renderIcon={() => (
              <Appointment className='Header-Icon' />
            )}
          />
          <div className='Appointments-Body'>
            <div className='Appointments-Filter'>
              <Form className='Appointments-FilterForm'>
                <DateField
                        hasTime
                        name='startDate'
                        value={startDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='С'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <DateField
                        hasTime
                        name='endDate'
                        value={endDate}
                        dateFormat='dd/MM/yyyy HH:mm'
                        timeFormat='HH:mm'
                        placeholder='По'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterDateField}
                />
                <TextField
                        name='clientName'
                        value={clientName}
                        placeholder='Клиент'
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <SelectField
                        name='statusId'
                        value={statusId}
                        placeholder='Статус'
                        options={[
                          { value: -1, text: '' },
                          ...map(
                            directory.appointment.status.list.dataSource.data,
                            o => ({ value: o.id, text: o.title })
                          )
                        ]}
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <CheckboxField
                        name='onlyMe'
                        label='Только я'
                        value={onlyMe}
                        className='Appointments-FilterField'
                        onChange={this.onChangeFilterField}
                />
                <Button
                        className='Appointments-SearchBtn'
                        onClick={this.onSearch}>
                  <Search className='Appointments-SearchBtnIcon'/>
                </Button>
              </Form>
            </div>
            <Table
                    data={ds.data}
                    isLoading={isFetching}
                    className='AppointmentList'
                    columns={[
                    {
                    dataField: 'date',
                    text: 'Дата',
                    headerStyle: {
                      width: '150px'
                    },
                    formatter: (v, row) => {
                      return (
                        <Moment date={v} format='DD.MM.YYYY HH.mm' />
                      )
                    }
                  },
                  {
                    dataField: 'clientName',
                    text: 'Клиент',
                    headerStyle: {
                      width: '300px'
                    }
                  },
                  {
                    dataField: 'status',
                    text: 'Статус'
                  },
                  {
                    dataField: 'holderName',
                    text: 'Принимающий',
                    headerStyle: {
                      width: '300px'
                    }
                  },
                  {
                    dataField: 'compliences',
                    text: 'Жалобы',
                    headerStyle: {
                      width: '200px'
                    }
                  },
                  {
                    dataField: 'diagnosis',
                    text: 'Диагноз',
                    headerStyle: {
                      width: '200px'
                    }
                  }
                ]}
              />
          </div>
        </div>
      )
    }
  }
  
  // объявляем контейнер
  export default connect(mapStateToProps, mapDispatchToProps)(Appointments)
  

Итак, проанализируем изменения:

  • Модифицирован метод mapStateToProps. В возвращаемом объекте появилось свойство directory, содержащее все справочные данные.

  • Модифицирован mapDispatchToProps. Добавлены действия, связанные со статусами.

  • Добавлен метод loadStatuses для загрузки статусов.

  • Добавлен компонент SelectField для выбора нужного статуса. Обратите внимание на первый option-элемент: { value: -1, text: '' }. Он всегда будет выбран по умолчанию.

Чтобы всё окончательно заработало осталось модифицировать логику фильтрации:


Код
    
  export function getAppointments (params) {
    const {
        statusId,
        startDate,
        endDate,
        clientName,
        onlyMe,
      } = params

    return filter(appointments, o => {
        return (startDate ? o.date >= startDate : true) &&
        (endDate ? o.date <= endDate : true) &&
        (clientName ? (clientName.length > 2 ? o.clientName.includes(clientName) : true) : true) &&
        (isNumber(statusId) && statusId >= 0 ? statusId === o.statusId : true) &&
        (onlyMe ? o.holderName === USER : true)
    })
  }
  

Теперь все необходимые изменения сделаны. Вы можете посмотреть и протестировать полный код приложения:





5.8.5.2 Способы сокращения кода


В предыдущем разделе мы ввели удобную абстракцию "справочные данные", которая помогла выделить специальный сервис, контроллер и поддерево состояния. Однако есть и ложка дёгтя: код <Appointments> стал больше. Обратите внимание на следующие методы:


Код
    
  load() {
   const {
     actions,
     dataSource: ds
   } = this.props

   actions.load({
       ...ds.filter.toJS()
   })
  }

  loadStatuses () {
   this.props.actions.status.list.load()
  }
  

Согласитесь, не самое приятное зрелище. Для вызова действия мы каждый раз должны вызывать this.props.actions, а с увеличением глубины дерева строка this.props.actions.status.list.load() будет ещё длиннее.

Давайте немного сократим код. Во-первых модифицируем функцию mapDispatchToProps():


Код
    
  // подключение генераторов действий к компоненту-контейнеру
  function mapDispatchToProps(dispatch) {
     return {
         actions: {
             ...bindActionCreators(appointmentListActions, dispatch),

             statuses: bindActionCreators(appointmentStatusListActions, dispatch)
         }
     }
  }
  

Во-вторых, используем такую нативную возможность JS-класса, как "геттер", чтобы сократить this.props.actions:


Код
    
  get actions () {
    return this.props.actions
  }
  

И тогда наши методы будут выглядеть заметно короче:


Код
    
 onChangeFilterField = (name, value) => {
   this.actions.changeFilterField(name, value)
 }

 onChangeFilterDateField = (name, value) => {
   this.actions.changeFilterField(name, value && value.getTime())
 }

 onSearch = () => {
   this.load()
 }

 get actions () {
   return this.props.actions
 }

 load() {
   this.actions.load({
       ...this.props.dataSource.filter.toJS()
   })
 }

 loadStatuses () {
   this.actions.status.list.load()
 }
  

Можно даже избавиться от метода changeFormField().





5.8.5.3 Декларативные действия. Компонент Action


А сейчас я предлагаю вам обратить своё внимание на другой аспект. На текущий момент мы в приложении все действия выполняем императивным путём:


Код
    
  doSomething () {
    this.actions.entity.doSomething()
  }
  

То есть объявляем и вызываем методы класса. Рост числа необходимых действий вызывает пропорциональный рост числа объявлений и вызовов соответствующих методов. Можно, конечно, не объявлять дополнительные методы и вызывать this.actions.entity.doSomething() напрямую. Однако, одно и то же действие иногда нужно выполнить два и более раз, поэтому отсутствие дополнительного метода класса может привести к росту кода и/или ухудшению его читабельности.

Столкнувшись с проблемой роста числа методов вида doSomething() я стал задумываться об идее декларативного выполнения действия. Моей целью стало создание/нахождение компонента, который решал бы следующие задачи:

  1. Выполнение действия должно происходить декларативным путём.

  2. Минимизация императивных действий в контейнерах.

  3. Сокращение кода контейнеров.

  4. Улучшение или хотя бы сохранение читабельности кода контейнеров.

Мне не удалось найти ничего готового для императивных действий redux. Поэтому, изрядно попотев, я пришел к простой и знакомой абстракции Action. Я вспомнил о своей разработке на Swing и Android, и позаимствовал у них несколько концепций.


Внимание!

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

У меня получился вот такой компонент:


Код
    
  import React, { Component } from 'react'

  import PropTypes from 'prop-types'

  const MOUNTING_PHASE = 'mounting'
  const UNMOUNTING_PHASE = 'unmounting'

  export default class Action extends Component {
      static propTypes = {
          action: PropTypes.func,
          params: PropTypes.object,
          onPerformed: PropTypes.func,
          shouldPerform: PropTypes.func,
          isMultiple: PropTypes.bool,
          performingPhase: PropTypes.oneOf([MOUNTING_PHASE, UNMOUNTING_PHASE])
      }

      static defaultProps = {
          action: () => {},
          isMultiple: false,
          onPerformed: () => {},
          shouldPerform: () => true,
          performingPhase: MOUNTING_PHASE
      }

      state = {
          count: 0
      }

      componentDidMount () {
          const { params, performingPhase } = this.props

          if (performingPhase === MOUNTING_PHASE && this.shouldPerform(params)) {
              this.perform().then(this.onPerformed)
          }
      }

      componentDidUpdate(prevProps) {
          const { count } = this.state
          const { isMultiple } = this.props

          if ((!count || isMultiple) && this.shouldPerform(prevProps.params)) {
              this.perform().then(this.onPerformed)
          }
      }

      componentWillUnmount() {
          const { params, performingPhase } = this.props

          if (performingPhase === UNMOUNTING_PHASE && this.shouldPerform(params)) {
              this.perform().then(this.onPerformed)
          }
      }

      onPerformed = result => {
          this.props.onPerformed(result)
      }

      shouldPerform (prevParams) {
          return this.props.shouldPerform(prevParams)
      }

      perform () {
          this.increaseCount()
          return new Promise(resolve => {
              resolve(this.props.action())
          })
      }

      increaseCount () {
          this.setState(s => ({ count: s.count + 1 }))
      }

      render () { return null }
  }
  

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


Код
    
      <Action action={this.actions.entity.doSomething}/>
      <Action
        params={{ someParam }}
        shouldPerform={prevParams => (
          someParam !== prevParams.someParam
        )}
        action={this.actions.entity.doSomething}
      />
      <Action
        isMultiple
        params={{ someParam }}
        shouldPerform={prevParams => (
          someParam !== prevParams.someParam
        )}
        action={this.actions.entity.doSomething}
      />
  
  

Теперь давайте проанализируем код компонента:

  • Само выполняемое действие(генератор) необходимо передать в свойство action.

  • Компонент имеет основной метод perform(), который означает выполнение действия.

  • Чтобы выполнить дополнительные действия после выполнения основного, следует передать функцию в свойство onPerformed. В эту функцию будет передан параметр result - результат выполненного действия.

  • Действие можно выполнить в фазе монтирования или демонтирования, о чём можно указать в свойстве performingPhase.

  • По умолчанию действие выполняется однократно в фазе монтирования. Если действие нужно выполнить многократно, необходимо передать свойство isMultiple=true.

  • Многократное выполнение действия должно быть связано с каким-то условием. Его нужно задать в свойстве shouldPerform. Это функция, которая принимает параметр prevParams. Когда вы выполняете действие с условием, то в компонент <Action> необходимо передать параметр params, который означает параметры действия. Сверив текущее значение параметра someParam и его предыдущее значение prevParams.someParam в методе shouldPerform можно принять однозначное решение о выполнении действия. Будьте внимательны и следите за тем, чтобы действие не выполнялось бесконечно!

Итак, введя такой компонент я избавился от императивного выполнения действия:


Код
    
  loadStatuses () {
    this.actions.statuses.load()
  }
  

И получил декларативное:


Код
    
  <Action action={this.actions.statuses.load} />
  

Таким образом я достиг решения задач 1, 2, 3 и, возможно, частично 4. Но этого мне было мало. Я тут же увидел, что можно пойти ещё дальше и создать контейнер-действие, который будет инкапсулировать строго одно определённое действие, а поместить такие контейнеры можно в папку /actions. Давайте создадим такой контейнер-действие для загрузки статусов приёмов, разместив его в папке /actions/directory. Исходный код компонента будет такой:


Код
    
  import React, { Component } from 'react'

  import { connect } from 'react-redux'
  import { bindActionCreators } from 'redux'

  import Action from '../../components/Action/Action'

  import * as actions from '../../redux/directory/appointment/status/list/appointmentStatusListActions'

  function mapDispatchToProps (dispatch) {
     return { actions: { ...bindActionCreators(actions, dispatch) } }
  }

  class LoadAppointmentStatusesAction extends Component {
     render () {
         return (
             <Action {...this.props} action={this.props.actions.load}/>
         )
     }
  }

  export default connect(null, mapDispatchToProps)(LoadAppointmentStatusesAction)
  

Теперь вместо <Action action={this.actions.statuses.load} /> я получил <LoadAppointmentStatusesAction/>. Запись заметно короче, что не может не радовать. Ещё одним приятным бонусом стала возможность повторного использования контейнера-действия.

Также следует отметить, что контейнер-действие использует компонент <Action> и может выполнять лишь одно заранее определённое действие. Кроме того, контейнер ведет себя абсолютно также как и сам <Action> за счёт делегирования свойств.

Глядя на код контейнера-действия, можно заметить, что он имеет типовую структуру. То есть все контейнеры-действия будут иметь почти одинаковый код. Это даёт потрясающую возможность создать фабрику действий ActionFactory, которая будет возвращать нужный контейнер-действие:


Код
    
  import React, { Component } from 'react'
  
  import { isFunction } from 'underscore'
  
  import { connect } from 'react-redux'
  import { bindActionCreators } from 'redux'
  
  import Action from '../components/Action/Action'
  
  export default function (actions, config = {}) {
  
      function mapDispatchToProps (dispatch) {
          return { actions: { ...bindActionCreators(actions, dispatch) } }
      }
  
      return connect(null, mapDispatchToProps)(class extends Component {
          render () {
              const {
                  params,
                  actions
              } = this.props
  
              const { action } = config
  
              if (!action) throw new Error(
                  '[CONFIGURATION]: "action" parameter is not specified'
              )
  
              if (!isFunction(action)) throw new Error(
                  '[CONFIGURATION]: "action" parameter must be a function'
              )
  
              return (
                  <Action
                      {...this.props}
                      action={() => action(params, actions)}
                  />
              )
          }
      })
  }
  

В итоге объявление контейнера-действия теперь будет таким:


Код
    
  import Factory from '../ActionFactory'
  
  import * as actions from '../../redux/directory/appointment/status/list/appointmentStatusListActions'
  
  export default Factory(actions, {
    // здесь мы сами устанавливаем, какой метод
    // подставить в действие action: (params, actions) => actions.someMethod(params)
    action: (params, actions) => actions.load()
  })
  

Потрясающе! Получилось очень коротко.

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

Конечно, использовать такой подход или нет, решать вам. Для кого-то он может показаться полным абсурдом, для кого-то слишком сложным и/или запутанным. Так или иначе, идея декларативного выполнения действия помогла мне очень сильно сократить код моих контейнеров, заметно повысить их читабельность, минимизировать императивную составляющую, снизить вероятность ошибок, дала преимущество повторного использования контейнеров-действий. Поэтому я решил поделиться моим решением с вами. Ваши мысли вы можете оставлять в комментариях, я буду стараться отвечать. Возможно, вы сможете предложить что-то существующее или гораздо лучшее!

Кстати, спустя время, пробегая глазами по библиотеке apollo, я наткнулся на нечто похожее. Там есть компонент запроса <Query>. Он выполняет GraphQL запрос, по окончании которого отрисовывает своё содержимое. Выглядит это так:


Код
    
  import gql from "graphql-tag";
  import { Query } from "react-apollo";

  const GET_DOGS = gql`
    {
      dogs {
        id
        breed
      }
    }
  `;

  const Dogs = ({ onDogSelected }) => (
    <Query query={GET_DOGS}>
      {({ loading, error, data }) => {
        if (loading) return "Loading...";
        if (error) return `Error! ${error.message}`;

        return (
          <select name="dog" onChange={onDogSelected}>
            {data.dogs.map(dog => (
              <option key={dog.id} value={dog.breed}>
                {dog.breed}
              </option>
            ))}
          </select>
        );
      }}
    </Query>
  );
  

Что ж, видимо создание нашего redux-аналога <Action> было не зря:)

Вот окончательный пример приложения после рефакторинга: