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

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

const state = {
  registrationProcess: {
    valid: true,
    submitDisabled: true,
  }
};

В реальных приложениях все еще сложнее. Во время отправки данных блокируется не только кнопка ввода, но и поле для ввода. Более того, отправка данных в одном месте, может повлиять и на остальные блоки на странице, которые могут пропадать, блокироваться или видоизменяться. Не говоря уже о том, что причин блокировки кнопки может быть больше чем одна. Она может быть заблокирована просто потому что в форму введены некорректные данные. В таком случае уже недостаточно простых true/false.

Решая эту задачу в лоб, можно получить такую структуру состояния:

const state = {
  registrationProcess: {
    valid: true,
    submitDisabled: true,
    inputDisabled: true,
    showSpinner: true
    blockAuthentication: true
  }
};

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

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

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

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

  • filling – заполнение формы. В этом состоянии все активно и доступно для редактирования.
  • processing (или sending) – отправка формы. Это то самое состояние, когда пользователь ждет, а приложение пытается предотвратить нежелательные действия, например клики или изменения данных формы.
  • processed (или finished) – Состояние обозначающее что все завершилось. В нем форма уже не отображается.

С точки зрения теории автоматов (а мы имеем дело с автоматным программированием в данном случае), такие состояния называются управляющими. Они определяют то, где мы сейчас находимся. Перепишем наше состояние:

const state = {
  registrationProcess: {
    state: 'filling',
  }
};

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

// Этих ифов может быть сколько угодно,
// главное что они завязаны на общее состояние, а не проверку конкретных флагов
if (state.registrationProcess.state === 'processing') {
    // Блокируем кнопки
    // Ставим спиннеры
}

Кроме таких состояний, есть различные данные, сопровождающие наш процесс. Например processed может завершиться с ошибками. В таком случае можно ввести дополнительно массив (или объект, в зависимости от структуры) с ошибками, который будет заполняться при их наличии:

const state = {
  registrationProcess: {
    errors: ['Имя не заполнено', 'Адрес имеет неверный формат'],
    state: 'processed',
  }
};

Причем этот же массив с ошибками удобно использовать для валидации формы до отправки на сервер. То есть будучи в состоянии filling.

А что если мы захотим блокировать возможность отправки кнопки до того момента, пока не пройдет фронтенд валидация? Есть два подхода, либо мы проверяем что errors пустой. Либо, что лучше, мы вводим явное состояние валидности формы. И тогда состояние нашего приложения становится таким:

const state = {
  registrationProcess: {
    errors: ['Имя не заполнено', 'Адрес имеет неверный формат'],
    state: 'processed',
    validationState: 'invalid' // или valid
  }
};

В некоторых ситуациях возможно объединение, когда процесс валидации соединен с процессом обработки самой регистрации. Тогда вместо отдельного состояния validationState, появится дополнительное состояние invalid внутри state. Это не совсем корректно с точки зрения моделирования (потому что у нас действительно два разных процесса), но иногда такой способ позволяет написать чуть более простой код (до тех пор пока различий не станет много).

Глобально, такой подход в разработке, называется программированием с явно выделенным состоянием. Он сводится к тому, что в рамках приложения находятся базовые процессы, от которых зависит все остальное. Затем эти процессы моделируются с помощью конечных автоматов (FSM). Причем не важно какие инструменты используются для разработки: чистый DOM, jquery или любой мощный современный фреймворк. Он применим везде и везде нужен.

Это невероятно мощная парадигма программирования, которая описана в одной из книг из наших рекомендаций.


Дополнительные материалы

  1. Xstate (библиотека для моделирования конечных автоматов)
Мы учим программированию с нуля до стажировки и работы. Попробуйте наш бесплатный курс «Введение в программирование» или полные программы обучения по Node, PHP, Python и Java.

Хекслет

Подробнее о том, почему наше обучение работает →