19 дек. 2024 г.·5 мин чтения

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

Архитектура побочных эффектов фронтенда выносит аналитику, трекинг и код тостов из продуктовых потоков. Используйте простой паттерн и избегайте типичных ошибок.

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

Почему смешение побочных эффектов ухудшает читабельность кода

Обработчик клика должен отвечать на простой вопрос: может ли пользователь выполнить это действие и что должно произойти дальше? Когда в том же блоке кроме этого ещё отправляют аналитику, пишут логи, показывают тост, открывают модальное окно и вызывают API — следить за потоком становится трудно.

async function onBuyClick() {
  if (!user) {
    analytics.track("checkout_blocked_guest");
    toast.error("Please sign in first");
    openLoginModal();
    return;
  }

  if (cart.items.length === 0) {
    analytics.track("checkout_blocked_empty_cart");
    toast.error("Your cart is empty");
    return;
  }

  setLoading(true);

  try {
    const order = await createOrder(cart);
    analytics.track("order_created", { orderId: order.id, total: cart.total });
    toast.success("Order placed");
    openOrderDetails(order.id);
  } catch (error) {
    errorReporter.capture(error);
    analytics.track("order_failed");
    toast.error("Payment failed");
  } finally {
    setLoading(false);
  }
}

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

Тесты быстро становятся шумными. Чтобы покрыть один обработчик, часто приходится мокать аналитику, тосты, отчёт ошибок и навигацию ещё до проверки самого правила. Тест, который должен говорить «гости не могут купить», превращается в сценарий про имена событий и тексты тостов.

Та же самая жара замедляет изменения продукта. Если меняются правила ценообразования, разработчик должен отредактировать правило и продолжить работу. Когда функция также владеет именами событий, UI‑копией и отчётом об ошибках, даже небольшая правка кажется рискованной. Люди избегают очистки, чтобы случайно не сломать метрики или обратную связь.

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

Что считается побочным эффектом в UI

Когда пользователь нажимает «Оплатить», продуктовая логика должна ответить на один вопрос: прошёл ли заказ? Всё остальное — реакция на это решение.

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

Простой тест помогает. Спросите: «Если я уберу эту строку, изменится ли продуктовое правило, или я только лишусь реакции вокруг него?» Если checkout по‑прежнему успешен, но маркетинг теряет событие purchase_completed, значит строка была побочным эффектом. Аналитика — самый явный пример, потому что она обычно наблюдает за поведением, а не решает его.

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

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

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

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

Выберите границу до рефакторинга

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

Простое правило помогает: фича решает, а слой побочных эффектов реагирует. Если сохранение формы прошло, фича может вернуть saved. Если нужен повторный вход — вернуть reauthRequired. Эти имена простые, короткие и легко тестируемые.

Избегайте названий исходов, которые уже содержат детали UI или трекинга. showUpgradeToastAndTrackPaywallView — не исход, а куча реакций.

Короткие имена обычно работают лучше: saved, validationError, paymentFailed, reauthRequired.

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

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

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

То же касается текста тостов. Держите пользовательскую копию в одном месте, когда её используют несколько экранов. Иначе изменение формулировки превращается в поиск по всему фронтенду.

Вынесение шумных частей по шагам

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

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

Теперь изменяйте продуктовую логику, а не побочные эффекты. Вместо вызова track(), toast() или logError() внутри потока — пусть поток возвращает простой исход вроде profile_saved, save_failed или email_invalid. Этот исход говорит, что произошло. Он не говорит, как приложение должно это объявить.

Затем добавьте небольшой адаптер или слушатель. Его единственная задача — реагировать на исходы. Увидев profile_saved, он может отправить событие аналитики и показать тост об успехе. Увидев save_failed, он может записать лог и показать сообщение об ошибке. Разделение простое, но меняет восприятие кода: поток снова читается как продуктовая логика.

Держите адаптер скучным. Он не должен решать бизнес‑правила, повторять запросы или преобразовывать данные. Если он начинает это делать, вы просто переместили беспорядок, а не исправили его. Адаптер должен только маппить исходы на побочные эффекты и останавливаться там.

После этого выполните то же действие пользователя и сравните результат. Событие должно дойти до аналитики. Тост должен появиться. Лог при ошибке — остаться. Если что‑то исчезло, значит новая граница не покрыла какой‑то случай.

Только затем переходите к следующему занятым потоку. Рефакторить пять потоков сразу — значит скрывать ошибки и усложнять откат.

Простой пример с формой оформления заказа

Отделить логику от реакций
Преобразуйте разбросанное трекинг- и UI-поведение в небольшой, понятный слой.

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

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

async function submitCheckout(input: CheckoutInput) {
  const stockOk = await inventory.check(input.items)
  if (!stockOk) return "out_of_stock"

  const paid = await payments.charge(input.payment)
  if (!paid) return "payment_error"

  await orders.create(input)
  return "success"
}

Эта функция легко просматривается. Она отвечает на один вопрос: какой исход попытки оформления заказа? Она ничего не знает о тексте тостов, именах событий или маршрутизации.

Обработчик сабмита может заняться побочными эффектами после получения результата.

const toastByOutcome = {
  success: "Order placed",
  out_of_stock: "Some items are no longer in stock",
  payment_error: "Payment failed. Check your card details and try again"
}

const analyticsEventByOutcome = {
  success: "checkout_success",
  out_of_stock: "checkout_out_of_stock",
  payment_error: "checkout_payment_error"
}

async function onCheckoutSubmit(input: CheckoutInput) {
  const outcome = await submitCheckout(input)

  analytics.track(analyticsEventByOutcome[outcome])
  toast.show(toastByOutcome[outcome])

  if (outcome === "success") router.goToConfirmation()
}

Теперь у каждой части одна задача. Бизнес‑правило возвращает success, out_of_stock или payment_error. Отдельный слой управляет аналитикой и тостами. Копия находится вне платежной логики, поэтому смена формулировки не заставит вас снова лезть в код checkout.

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

Где должны жить имена событий и тексты тостов

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

Храните имена событий в одном небольшом модуле. Этот модуль должен содержать стабильные ID событий и, возможно, несколько простых помощников для стандартной структуры полезной нагрузки. Он не должен знать про React‑состояние, ответы API или какая кнопка запустила действие.

Затем маппьте продуктовые исходы на аналитику в одном месте. Поток checkout не обязан строить payload прямо в обработчике клика. Он может вернуть простой исход вроде payment_succeeded, payment_failed_card или coupon_rejected, а маппер преобразует это в имя события и payload, которые ожидает ваша аналитика.

Это сохраняет продуктовый код читаемым. Обработчик сабмита валидирует ввод, вызывает API, обновляет локальное состояние и передаёт именованные побочные эффекты. Ему не нужно десять дополнительных строк для totалов, флагов купона, тестовых вариантов и временных меток.

Тексты тостов должны принадлежать UI‑слою. Пользователи читают эти сообщения, поэтому экран или фича должны владеть формулировкой. Имена аналитики — для систем. Смешивание обоих в одном хелпере обычно создаёт неудобный код.

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

Это важно также при локализации: тексты тостов меняются по языкам и контексту, а имена событий должны оставаться стабильными. Разделите эти заботы, и будущие правки остаются маленькими.

Ошибки, которые возвращают побочные эффекты обратно

Создать единый командный паттерн
Задайте простой паттерн, который разработчики смогут переиспользовать для checkout, signup и форм.

Беспорядок обычно возвращается через небольшие, разумные изменения. Команда выносит аналитику из компонента, затем кто‑то добавляет утилиту trackAction(type, data). Через месяц хелпер скрывает несколько вызовов трекера, сам добавляет контекст страницы и тихо меняет форму события. Продуктовый код стал чище, но никто не знает, что реально улетает.

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

Имена событий быстро расходятся, когда каждый компонент придумывает формулировки. Один модал логирует checkout_start, другой — started_checkout, третий — beginCheckout. Отчёты дробятся на три мнимые истории.

Тосты создают свою проблему. Команды часто используют текст тоста как состояние продукта: UI показывает «Payment failed», а потом какой‑то код проверяет эту строку, чтобы понять, что произошло. Это ломается при любой правке формулировки. Храните статус в структурированном состоянии, а тост используйте только для показа людям.

Первые признаки проблемы:

  • хелперы, которые скрывают дополнительную работу трекинга
  • билдеры событий, принимающие целые объекты
  • компоненты, свободно придумывающие имена событий
  • логика, проверяющая текст тоста вместо состояния
  • обёртки, которые никто не может объяснить в одном предложении

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

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

Чеклист для ревью

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

Когда компонент смешивает продуктовые правила с UI‑шумом, ревью замедляется. Люди перестают читать решение и начинают пролистывать вызовы событий, тексты тостов и хелперы повторных попыток.

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

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

Такое ревью хорошо работает в pull request'ах, потому что задаёт простой вопрос: может ли другой человек быстро прочитать продуктовую логику? Если нет — компонент ещё владеет слишком многим.

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

Следующие шаги для шумной команды фронтенда

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

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

Короткое правило команды полезнее длинного документа: продуктовая логика не вызывает трекеры напрямую. Если компонент должен что‑то отчитаться — он вызывает action, handler или domain‑функцию. Эта функция возвращает исход, а одна общая граница превращает исход в аналитику, логи и UI‑обратную связь.

Достаточно простого стартового паттерна. Храните стабильные имена событий в небольшом модуле. Держите тексты тостов рядом с фичей или экраном, который ими владеет. Помечайте прямые вызовы трекеров в pull request'ах и переносите их наружу при обнаружении. Для большинства команд этого уже достаточно.

Если в приложении уже есть несколько версий одного и того же паттерна, внешнее ревью может сэкономить много работы. Oleg Sotnikov на oleg.is работает как Fractional CTO и советник стартапов; такая постановка границ — практичный способ сделать фронтенд‑код легче для чтения, тестирования и изменений без полного переписывания.

Часто задаваемые вопросы

Что считается побочным эффектом во фронтенд-коде?

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

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

Почему смешанные побочные эффекты усложняют чтение обработчиков кликов?

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

Это также делает мелкие правки рискованными: изменение одной строки может повлиять на поведение продукта, отчётность и UI‑обратную связь одновременно.

Как отделить продуктовую логику от побочных эффектов?

Задайте один вопрос: решает ли эта строка, что произошло, или она реагирует на уже произошедшее? user must sign in first — это продуктовая логика. show a login modal — это последующее действие.

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

Какую границу стоит выбрать перед рефакторингом?

Выберите чётную границу и придерживайтесь её: feature решает исход, а другой слой реагирует на него. Фича может возвращать значения вроде saved, payment_failed или reauth_required.

Это делает правило понятным. Затем адаптер, хендлер или слушатель сопоставляет эти исходы с аналитикой, тостами, логами и навигацией.

Стоит ли функциям фич возвращать исходы вместо прямого показа тостов?

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

Если функция вызывает toast(), track() и делает маршрутизацию напрямую, она владеет слишком многим. Возврат исхода сокращает тесты и упрощает последующие изменения копии или аналитики.

Где должны жить имена событий аналитики?

Храните имена событий в одном небольшом модуле аналитики или в типизированном файле событий. Это даёт команде единый источник правды для стабильных имён и правил полезной нагрузки.

Если каждый компонент придумывает имена сам по себе, отчёты быстро расходятся и уборка становится сложнее.

Где должны храниться тексты тостов?

Текст тостов должен жить ближе к UI‑слою. Люди читают эти сообщения, поэтому экран или фича должны владеть формулировкой.

Не используйте текст тоста как состояние. Храните структурированный статус вроде payment_failed, а тост пусть просто описывает это состояние для пользователя.

Как безопаснее всего рефакторить запутанный поток?

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

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

Какие ошибки снова втягивают побочные эффекты обратно в код?

Частые ошибки: прятать логику в хелперах вместо её удаления. Обёртка типа trackAction() может превратиться в чёрный ящик, меняющий форму событий и добавляющий данные без контроля.

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

Когда стоит приглашать внешнюю помощь по архитектуре фронтенда?

Запросите внешнюю помощь, когда команда постоянно спорит о паттернах, тесты остаются шумными, или каждое изменение продукта затрагивает аналитику и UI‑обратную связь. Вам не нужен полный rewrite — нужна ясная граница и кто‑то, кто её зафиксирует.

Fractional CTO может просмотреть один загруженный поток, задать правила для исходов и событий и помочь команде распространить паттерн по приложению. Oleg Sotnikov (oleg.is) делает такую работу для стартапов и небольших команд, которые хотят чище фронтенд‑код без лишней суеты.