Dec 19, 2024·6 min read

Frontend side effects architecture for cleaner product code

Frontend side effects architecture keeps analytics, tracking, and toast code out of product flows. Use a simple pattern and avoid common mistakes.

Frontend side effects architecture for cleaner product code

Why mixed side effects make code hard to read

A click handler should answer a plain question: can the user do this action, and what should happen next? It gets hard to follow when the same block also sends analytics, writes logs, shows a toast, opens a modal, and calls an 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);
  }
}

None of those lines is wrong on its own. The problem is the pileup. One person adds a toast. Another adds tracking. Later someone logs errors, opens a modal, and tweaks the copy. Five small requests turn a short action into a long function, and the real product rule ends up buried in the middle.

Tests get noisy fast. To cover one handler, you often mock analytics, toasts, error reporting, and navigation before you can even check the rule. A test that should say "guest users cannot buy" turns into a script about event names and toast messages.

That same noise slows product changes. If pricing rules change, a developer should edit the rule and move on. When the function also owns event names, UI copy, and error reporting, even a small edit feels risky. People avoid cleanup because they do not want to break metrics or feedback by accident.

This is why side effects need structure. When product logic stays separate from analytics, logging, navigation, and toast handling, the code reads faster, tests stay shorter, and changes feel routine again.

What counts as a side effect in the UI

When a user clicks "Pay", your product logic should answer one question: did the order go through? Everything else is a reaction around that decision.

If the app also sends an analytics event, shows a toast, writes a log entry, saves something to storage, or sends the user to another screen, those are side effects. They matter, but they are not the decision itself. They happen because the decision happened, not the other way around.

A simple test helps. Ask, "If I remove this line, does the product rule change, or do I only lose a reaction around it?" If checkout still succeeds but marketing loses a "purchase_completed" event, that line was a side effect. Analytics is the clearest example because it usually observes behavior rather than deciding it.

The same idea applies to success and error toasts, logs sent to the console or an error service, redirects after an action finishes, writes to localStorage or sessionStorage, and background retries for analytics calls.

These actions often sit right next to the main code, so they start to feel like part of it. They are not. "User can apply a coupon" is product logic. "Show a green toast that says Coupon applied" is a reaction. "User must verify email before export" is product logic. "Redirect to the verify page" is a follow-up step.

Storage writes need a little care. Saving a dismissed banner flag is a side effect. Saving a draft form can also be one if that write does not decide whether the user can submit. Retries follow the same rule. Retrying an analytics request in the background does not change the checkout result. Retrying a payment request might change the result, so that belongs much closer to the business rule.

This separation keeps product code readable. You can scan one function and see the real rules first: who can do what, when, and why. Then you can move the noisy parts into a separate layer.

Pick a boundary before you refactor

Messy UI code usually gets worse when a team starts moving lines around without choosing a boundary first. Pick one clear seam: product code decides what happened, and another place decides what to show, log, store, or track.

A simple rule helps: the feature decides, and the side effects layer reacts. If a form save succeeds, the feature can return saved. If the user needs to sign in again, return reauthRequired. Those names are plain, short, and easy to test.

Avoid outcome names that already include UI or tracking details. showUpgradeToastAndTrackPaywallView is not an outcome. It is a pile of reactions.

Short names usually work best: saved, validationError, paymentFailed, reauthRequired.

Once you have those outcomes, create one place that maps them to side effects. That mapping can send an analytics event, show a toast, open a modal, or navigate to another screen. The feature stays readable because it answers only one question: what happened?

Teams often skip one decision and regret it later: who owns event names and payload fields. Decide that before you move code. If feature code invents some names, analytics code invents others, and QA checks a third version, the cleanup turns into another source of drift.

Pick one owner for the tracking schema. It can be a small analytics module, a typed event file, or simply one agreed place in the codebase. What matters is that feature code does not make up event names on the fly.

The same goes for toast text. Keep user facing copy in one place when several screens reuse it. Otherwise a wording change turns into a search project across the frontend.

Move the noisy parts out step by step

Start with one small path through the app. Pick something people use every day, like saving a profile or sending a form. Do not start with the whole page. One flow is enough to expose the clutter without turning the job into a rewrite.

Follow that flow from the user's click to the final screen state. Write down every extra action that happens on the way: analytics events, logs, success toasts, error toasts, maybe a call to a chat widget. Many teams know these calls exist, but they live in different components, hooks, and helpers. Once you list them in one place, the problem usually looks bigger than expected.

Now change the product logic, not the side effects. Instead of calling track(), toast(), or logError() inside the flow, let the flow return a plain outcome such as profile_saved, save_failed, or email_invalid. That outcome says what happened. It does not say how the app should announce it.

Then add a small adapter or listener. Its only job is to react to those outcomes. When it sees profile_saved, it can fire one analytics event and show a success toast. When it sees save_failed, it can write a log and show an error message. The split is simple, but it changes the feel of the code. The product flow reads like product logic again.

Keep the adapter boring. It should not decide business rules, retry requests, or reshape half the data. If it starts doing that, you moved the mess instead of fixing it. The adapter should map outcomes to side effects and stop there.

After that, run the same user action as before and compare the result. The same event should reach analytics. The same toast should appear. The same log should show up when the action fails. If one piece disappears, the new boundary is missing a case.

Only then move to the next busy flow. Refactoring five flows at once hides bugs and makes rollback harder.

A simple example with a checkout form

Untangle Mixed Side Effects
Bring structure to analytics, toasts, and product logic without a full rewrite.

Checkout is where side effects pile up fast. One submit can charge a card, check stock, log analytics, show a toast, reset the form, and send the user to a confirmation page. When all of that sits in one handler, the product rule gets buried.

Keep the product function small. Let it decide only what happened.

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"
}

That function is easy to scan. It answers one question: what was the outcome of the checkout attempt? It does not know anything about toast text, event names, or routing.

The submit handler can deal with side effects after it gets the result.

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()
}

Now each part has one job. The business rule returns success, out_of_stock, or payment_error. A separate layer handles analytics and toast behavior. The copy lives outside the payment logic, so a wording change does not send you back into checkout code.

This pays off the first time someone asks for a new event name or a softer error message. You edit a small map, not the checkout flow itself. Product code stays readable, and keeping analytics tracking isolated becomes much easier.

Where event names and toast text should live

When event names are scattered across buttons, forms, and effects, even a small product change gets messy fast. One screen sends checkout_submit and another sends submit_checkout, and now the same action shows up twice in reports.

Keep analytics event names in one small module. That module should store stable event IDs and maybe a few tiny helpers for common payload shapes. It should not know about React state, API responses, or which button started the action.

Then map product outcomes to analytics in one place. A checkout flow does not need to build a payload inside the click handler. It can report a plain outcome like payment_succeeded, payment_failed_card, or coupon_rejected, and a mapper can turn that into the event name and payload your analytics system expects.

This keeps product code readable. The submit handler can validate input, call the API, update local state, and hand off the named side effects. It does not need ten extra lines for totals, coupon flags, test variants, and timestamps.

Toast text should live closer to the UI layer. Users read those messages, so the screen or feature should own the wording. Analytics names are for systems. Toast copy is for people. Putting both in the same helper usually creates awkward code.

A simple split works well: one module owns event names, one mapper turns outcomes into analytics payloads, and each feature owns its toast copy. If the team changes the analytics schema, the user message stays untouched. If the product team rewrites the toast copy, reporting does not break.

This also matters once you add localization. Toast messages change by language and context, while event names should stay stable for a long time. Keep those concerns apart, and future edits stay small.

Mistakes that pull side effects back in

Separate Logic From Reactions
Turn scattered tracking and UI reactions into a small, clear layer.

The mess usually returns through small, reasonable changes. A team moves analytics out of a component, then someone adds a utility like trackAction(type, data). A month later that helper hides several tracker calls, adds page context on its own, and quietly changes the event shape. Product code looks cleaner, but nobody knows what actually fires.

Another common mistake is passing the full page state into every event builder. It feels easy because the data is already there. It also makes events vague, heavy, and tied to one screen. Pass the few fields the event needs and stop there.

Event names drift fast when each component invents its own wording. One modal logs checkout_start, another logs started_checkout, and a third logs beginCheckout. Reports split one action into three fake stories.

Toasts create a different problem. Teams often reuse toast text as product state. The UI shows "Payment failed", and later some code checks that string to decide what happened. That breaks as soon as someone edits the wording. Store status in structured state, and let the toast describe that state for people.

A few warning signs show up early:

  • helpers that hide extra tracking work
  • event builders that accept whole objects
  • components that invent event names freely
  • logic that checks toast text instead of state
  • wrappers nobody can explain in one sentence

Too many wrappers create their own mess. You do not need three layers of abstraction before the pattern proves itself. Start small. Remove repeated noise first. If a wrapper saves one line and adds another concept to learn, cut it.

A good setup feels boring. A button sends an intent. One place handles analytics, logging, and toast rules. The component stays readable, and the next person can change it without guessing.

Review checklist

Audit Your Frontend Rules
Get a senior technical eye on event naming, test noise, and boundary design.

When a component mixes product rules with UI noise, review gets slow. People stop reading the decision and start skimming past event calls, toast copy, and retry helpers.

A checkout form is a simple test case. If a discount code fails validation, you should spot the decision in a few lines: reject the code, keep the cart state, show feedback, and log the result. You should not need to scroll through five helper calls to understand what happened.

  • Read one product branch from top to bottom. Can you see the decision before you hit toast copy or tracking details?
  • Check where analytics names live. One module should own them, so checkout_failed does not appear in random components with tiny spelling changes.
  • Look at the tests for that branch. A test should cover the decision with one boundary mocked, not three or four services.
  • Open a component that triggers side effects. It should call one boundary, not stitch together analytics, logging, and notifications by hand.
  • Pick one outcome, such as payment_declined. Product and QA should be able to trace it to one event and one user message without guessing which path ran.

This kind of review works well in pull requests because it forces a simple question: can another person read the product logic quickly? If the answer is no, the component still owns too much.

When a page fails two or three of these checks, skip the big rewrite. Pull out one noisy area first, usually analytics or toasts. That small change often makes the next cleanup obvious, and the code gets easier to test right away.

Next steps for a messy frontend team

If the frontend feels noisy, do not try to clean the whole app at once. Pick one flow that wastes team time every week, such as checkout, signup, or password reset. Small wins beat a wide refactor that stalls by Friday.

Refactor only that path first. Move analytics calls, logs, and toast messages behind one small layer. Product code should decide facts like order_saved or payment_failed. A separate function should decide which event fires and which toast the user sees.

A short team rule helps more than a long document: product logic does not call trackers directly. If a component needs to report something, it should call an action, handler, or domain function. That function returns an outcome, and one shared boundary turns that outcome into analytics, logs, and UI feedback.

A simple starter pattern is enough. Keep stable event names in a small events module. Keep toast copy near the feature or screen that owns it. Flag direct tracker calls in pull requests and move them out when you see them. That is already enough structure for most teams.

If your app already has several versions of the same pattern, an outside review can save a lot of churn. Oleg Sotnikov at oleg.is works as a Fractional CTO and startup advisor, and this kind of boundary setting is a practical way to make frontend code easier to read, test, and change without a rewrite.

Frequently Asked Questions

What counts as a side effect in frontend code?

A side effect is any reaction around the product decision, not the decision itself. Analytics calls, toasts, logs, redirects, and storage writes all fit that pattern.

If checkout still works after you remove a line, and you only lose a reaction like tracking or feedback, that line is a side effect.

Why do mixed side effects make click handlers hard to read?

The handler stops reading like a product rule and starts reading like a pile of reactions. You have to scan past tracking, toast text, logging, and routing just to see what the app decided.

That also makes small edits feel risky, because one change can touch product behavior, reporting, and UI feedback at the same time.

How do I separate product logic from side effects?

Ask one question: does this line decide what happened, or does it react to what happened? user must sign in first is product logic. show a login modal is a follow-up.

Another quick test helps. Remove the line in your head. If the business result stays the same, you found a side effect.

What boundary should I choose before refactoring?

Pick one seam and keep it steady: the feature decides the outcome, and another layer reacts to it. The feature can return values like saved, payment_failed, or reauth_required.

That keeps the rule plain. Then one adapter, handler, or listener can map those outcomes to analytics, toasts, logs, and navigation.

Should feature functions return outcomes instead of showing toasts directly?

Return outcomes first. A feature function should say what happened, not how the app should announce it.

When the function calls toast(), track(), and routing directly, it owns too much. Returning an outcome keeps tests shorter and makes later copy or analytics changes much easier.

Where should analytics event names live?

Keep event names in one small analytics module or typed events file. That gives the team one source for stable names and payload rules.

If each component invents names on the fly, reports split fast and cleanup gets messy.

Where should toast messages live?

Toast copy belongs near the feature or screen that owns the user message. People read that text, so the UI layer should control it.

Do not use toast text as state. Store a real status like payment_failed, then let the UI describe it in plain language.

What is the safest way to refactor a messy flow?

Start with one noisy flow that the team touches all the time, like checkout or profile save. Change the product path first so it returns plain outcomes.

After that, add a small adapter that turns each outcome into the same analytics events, toasts, and logs you already had. Check that behavior stayed the same before you move on.

What mistakes pull side effects back into the code?

Teams often hide the mess inside helpers instead of removing it. A wrapper like trackAction() can grow into a black box that changes event shapes and adds extra data without anyone noticing.

Another problem shows up when components pass whole page objects into event builders or check toast text to decide state. Keep inputs small and keep state structured.

When should a team ask for outside help with frontend architecture?

Bring in outside help when the team keeps arguing about patterns, tests stay noisy, or every product change touches analytics and UI feedback. You do not need a rewrite, but you do need a clear boundary and someone to enforce it.

A Fractional CTO can review one busy flow, set the event and outcome rules, and help the team apply the same pattern across the app. Oleg Sotnikov does this kind of work for startups and small teams that want cleaner frontend code without extra churn.