Управление потоками данных во фронтенде: уход от глобальных состояний — Work Solutions
Организация потоков данных во фронтенд-приложениях: уход от глобальных состояний
ГлавнаяБлогРазработчикамОрганизация потоков данных во фронтенд-приложениях: уход от глобальных состояний
Разработчикам24 января 2025

Организация потоков данных во фронтенд-приложениях: уход от глобальных состояний

Фотография автора
Александр ШапоровFrontend-разработчик

Каждый разработчик рано или поздно сталкивается с моментом, когда проект начинает выходить из-под контроля. Это похоже на ремонт в квартире — начинается с покраски одной стены, а заканчивается полной перепланировкой. На «WS митап №1: фронтенд» разработчик Work Solutions Александр Шапоров поделился опытом работы над крупным React-проектом, где команда столкнулась именно с такой ситуацией. То, что начиналось как обычная доработка функционала, превратилось в глубокое переосмысление архитектуры приложения и борьбу с «монстром» глобальных состояний.

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

Контекст и предыстория

В Work Solutions сформирована система принципов разумного баланса между техническим совершенством и бизнес-требованиями. Компания не гонится за идеальными решениями там, где они избыточны, но и не допускает технического долга там, где он может стать критичным. Этот подход основан на глубоком изучении работ признанных экспертов отрасли — Роберта Мартина с его книгами «Чистый код» и «Чистая архитектура», а также Мартина Фаулера и его трудов по рефакторингу и шаблонам корпоративных приложений.

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

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

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

Инициализация

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

Жизненные циклы

Но самое интересное начинается, когда мы говорим о жизненном цикле этих состояний. И тут хочется поделиться метафорой, которая отлично иллюстрирует проблему. Представьте жизненный цикл комара: яйцо, личинка, куколка и взрослая особь. Каждая стадия четко определена, как и в React-компонентах — монтирование, обновление, размонтирование. Но глобальные состояния существуют в странном промежуточном состоянии, которое мы в команде прозвали «то ли пупа, то ли лярва».

Изображение статьи

Это состояние, когда данные создаются при запуске приложения и живут до его закрытия, постепенно обрастают побочными эффектами и неконтролируемыми мутациями. Они не следуют естественному жизненному циклу компонентов, вместо этого зависают в промежуточном состоянии «то ли пупа, то ли лярва», создавая неожиданные проблемы.

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

Этот случай наглядно демонстрирует, почему глобальные состояния становятся источником сложно отлавливаемых багов. Без очистки состояния через перезагрузку страницы каждая следующая операция выполняется в контексте «загрязненного» предыдущими действиями состояния. Это превращает тестирование в настоящий квест, где разработчику приходится не только проверять корректность самой функциональности, но и учитывать все возможные «хвосты» от предыдущих операций.

Проблема 149 usages на одно поле

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

Неожиданные побочные эффекты: эффект снежного кома

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

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

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

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

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

Бесконечные проверки типов: когда TypeScript бессилен

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

Рассмотрим типичную ситуацию: в приложении есть форма, значение в которой может быть либо строкой, либо нулем. Логично предположить, что достаточно один раз проверить это значение где-то в корневом компоненте. Например, если значение равно нулю, можно просто не рендерить дочерние компоненты. Но TypeScript не может отследить эту логику через дерево компонентов. Изображение статьи

В попытке обойти эти ограничения разработчики часто прибегают к использованию non-null assertions — специальному синтаксису TypeScript с восклицательным знаком, который говорит компилятору «доверься мне, я знаю, что делаю». Это выглядит как простое решение, но создает новые проблемы. 

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

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

Глобальные состояния: как хорошие практики превращаются в антипаттерны

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

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

Почему так происходит: путь разработчика

Корень проблемы лежит в типичном пути обучения фронтенд-разработчика. Сначала изучается JavaScript и базовое взаимодействие со страницей, затем React или другие фреймворки, а следом — концепция глобального состояния. В результате глобальные сторы начинают восприниматься как универсальное решение для любой задачи управления состоянием.

Теория разбитых окон во фронтенд-разработке

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

Теория получила широкую известность после успешного эксперимента в метро Нью-Йорка в конце 1980-х годов. Тогдашний директор метрополитена Дэвид Ганн начал борьбу с преступностью именно с наведения чистоты в вагонах и удаления граффити. Результаты превзошли ожидания — уровень преступности в подземке значительно снизился.

В контексте фронтенд-разработки эта теория работает удивительно похоже: появление одного глобального состояния часто провоцирует создание других. Изображение статьи

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

Решение проблемы

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

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

Новый метод: как приручить поток данных

Для решения накопившихся проблем с глобальными состояниями команда Work Solutions разработала новый метод организации потока данных в приложении. В его основе лежит четкое разделение ответственности и контролируемый жизненный цикл данных.

Изображение статьи

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

Уровень взаимодействия с API

На первом этапе все начинается с запросов к серверу. В современной фронтенд-разработке для этого обычно используются такие инструменты как Axios или Fetch. Однако вместо прямого использования этих библиотек команда разработала собственный класс RequestManager, который инкапсулирует всю логику работы с API.

Изображение статьи

RequestManager — это не просто обертка над Axios или Fetch. Это полноценный менеджер запросов со всеми необходимыми настройками, обработкой ошибок и другими важными функциями. Такой подход позволяет централизовать всю логику взаимодействия с сервером в одном месте, что значительно упрощает поддержку и модификацию кода.

Однако получение данных от сервера — это только начало пути. Когда мы делаем запрос и получаем ответ в формате JSON, возникает важный вопрос: действительно ли мы получили именно те данные, которые ожидали? В реальной разработке нельзя слепо доверять входящим данным. Бэкенд мог изменить структуру ответа, добавить или убрать поля, или прислать невалидные данные. 

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

  • Некоторые обязательные поля могут отсутствовать;
  • Типы данных могут не соответствовать ожидаемым;
  • Формат дат или других специальных значений может отличаться от требуемого;
  • В ответе могут присутствовать лишние поля, которые мы не ожидали.

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

Декодеры: надежная валидация входящих данных

В новой архитектуре все входящие данные от сервера в обязательном порядке проходят через декодеры. В самом простом понимании декодер — это функция, которая принимает на вход любые данные (unknown), а на выходе гарантированно выдает объект с правильной структурой и типами. Если же входящие данные не соответствуют ожиданиям, декодер не пытается их «починить», а сразу выбрасывает ошибку. Изображение статьи

Такой подход закрывает сразу несколько важных задач:

  1. Валидация структуры данных — проверяется наличие всех необходимых полей;
  2. Контроль типов — каждое поле проверяется на соответствие ожидаемому типу;
  3. Преобразование данных — при необходимости данные могут быть преобразованы в нужный формат;
  4. Раннее обнаружение проблем — ошибки в структуре данных выявляются сразу при получении ответа от сервера. Изображение статьи

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

React Context: правильная локализация состояний

После прохождения через декодеры и валидацию данные попадают в классы сущностей (TaskEntity) или табличные модули (TasksTable). Здесь начинается работа с контекстами — вместо глобальных состояний используются React Context или Vue provide.

Изображение статьи

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

Бизнес-логика и сервисы

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

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

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

От сервиса к бэкенду: подготовка данных для отправки

Когда сервис выполняет какую-либо операцию, на выходе формируется специальный объект — DTO (Data Transfer Object). Например, при изменении статуса задачи создается task-update-dto, который описывает только те поля, которые нужно обновить. В простейшем случае это может быть только поле completed.

Изображение статьи

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

Изображение статьи

Например, если фронтенд использует task-update-dto, а бэкенд ожидает данные в формате form-data, энкодер выполнит необходимое преобразование. Это позволяет фронтенду и бэкенду работать в своих удобных форматах, не завися друг от друга.

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

Изображение статьи

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

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

Изображение статьи

Преимущества нового подхода: почему сложность оправдана

На первый взгляд предложенная архитектура может показаться излишне сложной. Однако каждое архитектурное решение в ней направлено на решение конкретных проблем и приносит ощутимую пользу: 

  • Надежность и предсказуемость. Помните метафору с комарами из начала статьи и состоянием «то ли пупа, то ли лярва»? В новой архитектуре эта проблема полностью решена. Таблицы и состояния создаются точно в тот момент, когда они нужны, с правильной инициализацией и всеми необходимыми настройками. Система точно знает, на какой странице находится пользователь и какие параметры фильтрации потребуются. А когда страница закрывается, все ненужные данные корректно уничтожаются — никакого «подвешенного» состояния;
  • Принцип единственной ответственности. Архитектура следует принципу Single Responsibility, что значительно упрощает поддержку кода. Если нужно изменить именование полей, достаточно обновить внутренние интерфейсы и подстроить декодеры и энкодеры. При этом не требуется выполнять глобальный поиск по проекту и корректировать многочисленные места их использования в разных частях приложения;
  • Независимость от бэкенда. Благодаря собственным интерфейсам на фронтенде и системе декодеров/энкодеров, фронтенд-приложение больше не зависит напрямую от структуры API. Все преобразования данных происходят в специальных для этого местах, что делает код более организованным и упрощает внесение изменений;
  • Отсутствие повторов и слабая связанность. Следуя принципу DRY (Don't Repeat Yourself), новая архитектура позволяет избежать дублирования кода. Модули слабо связаны между собой, что позволяет изменять одни части системы, не затрагивая другие.

Этот подход можно применять и без использования TypeScript и интерфейсов — код будет работать, хотя и потеряет в выразительности и удобстве разработки (например, не будет автодополнения в IDE).

Рекомендации по внедрению новой архитектуры данных: с чего начать

При внедрении новой архитектуры важно действовать постепенно и методично. Вот несколько практических рекомендаций по переходу на новый подход:

Начните с разделения кода

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

Выберите пилотную сущность

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

О повторах кода

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

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

Сдвигаем архитектурные границы: от Swagger к независимости

В традиционной разработке граница между фронтендом и бэкендом обычно проходит через Swagger — инструмент, который визуализирует API и показывает, какие эндпоинты доступны, какие объекты ожидаются и как происходит обмен данными. Это как пограничный пост между двумя частями приложения.

Изображение статьи

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

Изображение статьи

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

Такой подход дает интересные преимущества. Например, для тестирования можно легко подменить весь бэкенд, просто замокав RequestManager. А если бэкенд решит изменить формат данных, достаточно будет обновить только декодеры и энкодеры, не затрагивая основную логику приложения. Фронтенд становится действительно независимым: он сам решает, когда и как реагировать на изменения в API.

Преимущества системы

  • Четкие правила вместо хаоса. Главное преимущество нового подхода — наличие четких правил и структуры. Когда все классы описаны и есть типовые примеры, разработка становится более предсказуемой. Больше не нужно «хакать» приложение, пытаясь вписать еще пару строчек в существующий код.
  • Унификация и скорость разработки. Благодаря единообразию кода и общим утилитам многие операции становятся тривиальными. Например, создание нового провайдера сводится буквально к трем строчкам кода — достаточно передать класс в утилиту, и она сгенерирует все необходимые хуки и провайдеры.
  • Контроль над состоянием. В новой архитектуре нет места «вечноживущим» глобальным состояниям. Даже такие традиционно глобальные вещи как current user управляются через контексты. При выходе со страницы все неиспользуемые данные корректно уничтожаются, а при входе на новую страницу вы всегда начинаете с чистого листа. Если же нужно сохранить какие-то данные между переходами, это делается осознанно и контролируемо.

Ограничения и сложности системы

Повышенный порог входа. Главный минус подхода — его кажущаяся сложность. Мне это чем-то напоминает мем «Кто понял, тот понял, а кто не понял — тот не понял». Здесь речь идет не столько о сложности, сколько о необходимости изучить и принять новые правила игры.

Избыточность для малых проектов. Для небольших приложений или прототипов этот подход может оказаться слишком тяжеловесным — как стрельба из пушки по воробьям. Если вы делаете черновое PoC (Proof of Concept), которое через месяц выкинете в корзину, возможно, не стоит тратить время на построение сложной архитектуры.

Технические ограничения системы

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

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

Технологический стек

При реализации описанной архитектуры главные роли были распределены следующим образом:

  • Для валидации данных: jsonous. Библиотека для декларативной валидации и парсинга JSON. Она отвечает за проверку и преобразование данных, приходящих с сервера, обеспечивая надежную типизацию и валидацию.
  • Для работы с API: кастомный RequestManager. Обертка над Axios с дополнительными настройками и функциональностью. Важный момент: любой HTTP-клиент лучше оборачивать в собственный класс. Это защитит от необходимости масштабного рефакторинга при смене библиотеки для запросов.
  • Для энкодеров: Ramda. Библиотека для функционального программирования, которая отлично подходит для преобразования данных. Позволяет писать декларативный и понятный код для трансформации данных перед отправкой на сервер.
  • Для управления состоянием: MobX. Все сущности и таблицы реализованы как реактивные MobX-классы. Они наследуются от базовых классов, которые предоставляют готовую функциональность для работы с фильтрацией, сортировкой и управлением состоянием.
  • Для внедрения зависимостей: React.createContext / Vue.provide. Встроенные механизмы React и Vue для внедрения зависимостей. Они позволяют организовать доступ к данным и сервисам на нужном уровне приложения.

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

148
50

Другие статьи

Ко всем статьям
Фоновое изображение: четверть круга закрыват часть круга

Интересные статьи и кейсы
от Work Solutions

Нажимая кнопку «Подписаться», я даю согласие на обработку персональных данных

Спасибо за подписку!

Фоновое изображение: верхний полукруг