Зарегистрируйтесь, чтобы продолжить обучение

Программирование с явно выделенным состоянием JS: Архитектура фронтенда

Современные фронтенд-приложения включают множество элементов, которые должны корректно реагировать на изменения данных: спиннеры крутятся, кнопки отключаются, данные отправляются. Управление этим процессом требует продуманного подхода. В идеале, любые изменения в интерфейсе являются следствием изменения данных, то есть состояния приложения. Представьте себе форму регистрации, у которой кнопка отправки (submit) заблокирована во время выполнения запроса на сервер (с точки зрения UX это обязательно для любых форм). В таком случае состояние может приобрести следующий вид:

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

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

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

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

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

Но такой подход очень быстро усложняет разработку. Когда состояние описывается через множество независимых флагов, может возникнуть логическая путаница. Например, если форма валидна (valid: true), но submitDisabled: true, пользователь не сможет отправить ее, хотя логически это должно быть возможно. При увеличении числа таких флагов усложняется понимание взаимосвязей и проверок.

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

// Логика вывода в UI
const showSubmitButton =
  state.registrationProcess.valid && !state.registrationProcess.submitDisabled;
const showSpinner = state.registrationProcess.showSpinner;
const disableInput =
  state.registrationProcess.inputDisabled ||
  state.registrationProcess.blockAuthentication;

console.log({ showSubmitButton, showSpinner, disableInput });
// { showSubmitButton: false, showSpinner: true, disableInput: true }

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

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

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

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

Использование одного свойства state вместо нескольких флагов упрощает логику. Например, состояние processing автоматически определяет, что форма заблокирована, спиннер активен, а кнопка submit отключена — и для этого не нужно держать три отдельных флага. Такой подход снижает вероятность багов и делает код более читаемым.

Перепишем наше состояние убрав оттуда все флаги и введя одно свойство отвечающее за состояние работы с формой:

const state = {
  registrationProcess: {
    state: "filling", // 'processing', 'processed', 'failed'
  },
};

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

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

if (state.registrationProcess.state === "failed") {
  // Выводим сообщение об ошибке
}

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

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

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

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

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

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

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

Собирая все вместе

Пример ниже демонстрирует этот подход на простой форме регистрации.

<!doctype html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <title>Форма с явным состоянием</title>
  </head>
  <body>
    <form id="registrationForm">
      <input type="email" id="emailInput" placeholder="Введите email" />
      <button type="submit" id="submitButton" disabled>Отправить</button>
      <div id="message"></div>
    </form>

    <script>
      const state = {
        registrationProcess: {
          state: "filling", // "processing", "failed", "success"
          errors: [],
        },
      };

      const form = document.getElementById("registrationForm");
      const emailInput = document.getElementById("emailInput");
      const submitButton = document.getElementById("submitButton");
      const message = document.getElementById("message");

      function validateEmail(email) {
        return email.trim() !== ""; // Простая проверка: email не должен быть пустым
      }

      function updateUI() {
        const { state: processState, errors } = state.registrationProcess;

        if (processState === "processing") {
          submitButton.disabled = true;
          message.textContent = "Отправка...";
        } else if (processState === "failed") {
          submitButton.disabled = false;
          message.textContent = `Ошибка: ${errors.join(", ")}`;
        } else if (processState === "success") {
          submitButton.disabled = true;
          message.textContent = "Успешно отправлено!";
        } else {
          submitButton.disabled = state.registrationProcess.errors.length > 0;
          message.textContent = "";
        }
      }

      emailInput.addEventListener("input", () => {
        const email = emailInput.value;
        state.registrationProcess.errors = validateEmail(email)
          ? []
          : ["Некорректный email"];
        updateUI();
      });

      form.addEventListener("submit", (event) => {
        event.preventDefault();

        if (state.registrationProcess.errors.length > 0) return;

        state.registrationProcess.state = "processing";
        updateUI();

        setTimeout(() => {
          if (validateEmail(emailInput.value)) {
            state.registrationProcess.state = "success";
          } else {
            state.registrationProcess.state = "failed";
            state.registrationProcess.errors = ["Ошибка при отправке"];
          }
          updateUI();
        }, 2000);
      });

      updateUI();
    </script>
  </body>
</html>

Разбор кода

  • Явное состояние state.registrationProcess.state

    • filling – ввод данных в поле.
    • processing – отправка формы, блокировка кнопки и поля.
    • success – успешная регистрация, кнопка отключается.
    • failed – ошибка валидации, отображается сообщение.
  • Функция updateUI()

    • Управляет блокировкой кнопки и полем ввода.
    • Показывает сообщения "Отправка...", "Ошибка..." или "Успешно отправлено!".
    • Блокирует кнопку, если валидация не пройдена.
  • Валидация email при вводе

    • Если email пуст, кнопка Submit остается заблокированной.
    • Ошибка отображается сразу, без необходимости отправки.
  • Отправка формы (submit обработчик)

    • При клике кнопка блокируется, состояние "processing".
    • Через setTimeout() эмулируется серверный ответ.
    • Если email валиден → success, иначе → failed.

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


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

  1. Xstate

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

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

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 25 000 ₸ в месяц
Разработка фронтенд-компонентов для веб-приложений
10 месяцев
с нуля
Старт 3 апреля
профессия
от 39 525 ₸ в месяц
Разработка фронтенд- и бэкенд-компонентов для веб-приложений
16 месяцев
с нуля
Старт 3 апреля
профессия
новый
Разработка фронтенд- и бэкенд-компонентов для веб-приложений на Spring Boot и React
16 месяцев
с нуля
Старт 3 апреля
профессия
новый
16 месяцев
с нуля
Старт 3 апреля

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»