• Event-Driven UI Without Turning Events Into a Mess

    Client-side code gets tangled when one function knows too much about the rest of the page.

    A cart API call updates the badge in the header, the item row, the mini-cart, the analytics layer, maybe a promo widget. The tempting solution is to inject callbacks everywhere or make the API function reach into every component directly. That works for a week. Then every new feature turns into another conditional branch in the same place.

    Events are useful because they invert that dependency.

    The producer says what happened. Consumers decide whether they care.

    The Old Shape

    In 2014, the convenient way to do this in many browser apps was jQuery:

    $('body').trigger('QuantityChanged', [itemId, quantity]);

    That was reasonable for its time, but I would not teach it as the default now. A global DOM node as an application event bus is too easy to abuse, and jQuery is no longer the baseline dependency for browser code.

    The idea still holds. The implementation should be smaller and more explicit.

    Use A Local EventTarget

    The browser already has an event primitive:

    export const cartEvents = new EventTarget();
    
    export const CART_ITEM_CHANGED = 'cart:item-changed';
    export const CART_REQUEST_FAILED = 'cart:request-failed';

    Now the cart API can publish domain facts without importing UI modules:

    import {
      CART_ITEM_CHANGED,
      CART_REQUEST_FAILED,
      cartEvents,
    } from './cart-events.js';
    
    export async function addItem(itemId, quantity) {
      try {
        const response = await fetch('/api/cart/items', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ itemId, quantity }),
        });
    
        if (!response.ok) {
          throw new Error(`Cart request failed: ${response.status}`);
        }
    
        const item = await response.json();
    
        cartEvents.dispatchEvent(
          new CustomEvent(CART_ITEM_CHANGED, {
            detail: {
              itemId: item.id,
              quantity: item.quantity,
              lineTotal: item.lineTotal,
              cartTotal: item.cartTotal,
            },
          }),
        );
    
        return item;
      } catch (error) {
        cartEvents.dispatchEvent(
          new CustomEvent(CART_REQUEST_FAILED, {
            detail: { itemId, quantity, error },
          }),
        );
    
        throw error;
      }
    }

    The important part is not EventTarget itself. The important part is the direction of knowledge:

    • addItem() knows that a cart item changed;
    • it does not know which components exist;
    • each component subscribes to the facts it needs.

    Subscribe Near The Component

    A badge can listen for the cart change:

    import { CART_ITEM_CHANGED, cartEvents } from './cart-events.js';
    
    export function mountCartBadge(element) {
      const controller = new AbortController();
    
      cartEvents.addEventListener(
        CART_ITEM_CHANGED,
        (event) => {
          element.textContent = String(event.detail.cartTotal);
        },
        { signal: controller.signal },
      );
    
      return () => controller.abort();
    }

    An item row can listen to the same event and ignore unrelated items:

    import { CART_ITEM_CHANGED, cartEvents } from './cart-events.js';
    
    export function mountCartRow(element, itemId) {
      const controller = new AbortController();
    
      cartEvents.addEventListener(
        CART_ITEM_CHANGED,
        (event) => {
          if (event.detail.itemId !== itemId) {
            return;
          }
    
          element.querySelector('[data-role="quantity"]').textContent = String(
            event.detail.quantity,
          );
          element.querySelector('[data-role="line-total"]').textContent =
            formatMoney(event.detail.lineTotal);
        },
        { signal: controller.signal },
      );
    
      return () => controller.abort();
    }

    The AbortController is not decorative. Event listeners are references. If a component goes away and the listener stays registered, the old DOM node can stay reachable too. Passing signal to addEventListener() gives the component a single cleanup handle.

    Do Not Split Facts Too Far

    The older version of this article argued for very atomic events such as QuantityChanged and PriceChanged.

    That advice needs a sharper edge.

    Events should describe one meaningful fact from the domain, not one arbitrary field. If quantity, line total, and cart total come from the same server response, splitting them into three separate events can make the UI observe impossible intermediate states.

    This is usually better:

    cartEvents.dispatchEvent(
      new CustomEvent('cart:item-changed', {
        detail: {
          itemId,
          quantity,
          lineTotal,
          cartTotal,
        },
      }),
    );

    This is usually worse:

    cartEvents.dispatchEvent(
      new CustomEvent('cart:quantity-changed', { detail: { itemId, quantity } }),
    );
    cartEvents.dispatchEvent(
      new CustomEvent('cart:line-total-changed', { detail: { itemId, lineTotal } }),
    );
    cartEvents.dispatchEvent(
      new CustomEvent('cart:total-changed', { detail: { cartTotal } }),
    );

    Small listeners are good. Artificially fragmented facts are not.

    Keep The Bus Narrow

    An event bus is not a state manager. It does not give you history, derived state, time travel, retries, ordering across async work, or a consistent snapshot of the world.

    Use events for edges:

    • a domain operation completed;
    • a component wants to notify its owner;
    • an integration layer needs to observe something without becoming a dependency;
    • loosely related UI pieces need to react to the same fact.

    Avoid events for core state:

    • form state that needs validation and error display;
    • server cache state;
    • a multi-step workflow where every step depends on the previous one;
    • data that most of the screen reads on every render.

    For those cases, use the model your framework expects: React state, context, an external store, Redux Toolkit, Zustand, Pinia, RxJS, or a query/cache library. The point is not to avoid tools. The point is to avoid making hidden event flows your real architecture.

    Name Events Like Contracts

    The event name is part of the API. Treat it that way.

    Prefer names that describe what happened:

    'cart:item-changed';
    'cart:request-failed';
    'checkout:submitted';

    Avoid names that describe what some listener should do:

    'updateHeader';
    'refreshEverything';
    'rerenderCart';

    The second style smuggles UI knowledge back into the producer. Once the producer starts naming listeners’ jobs, the dependency inversion is gone.

    The Rule I Use Now

    Use events when the producer should not know its consumers.

    Do not use events when the producer and consumer are already in a clear parent-child relationship, or when the data is important enough to deserve a real state model.

    That is the whole tradeoff. Events are excellent glue. They are a poor database.