Building a Modern Frontend Event System from Scratch
From spaghetti code to clean, decoupled code: building a powerful event system that scales with your application
Building a Modern Frontend Event System from Scratch
If you’ve worked on a frontend from ages ago, you’ve probably seen it: the spaghetti code, the tangled mess of addEventListener
calls, and the confusing web of direct function calls that make updates and debugging a nightmare. As an application grows, this approach quickly becomes unmaintainable.
We’ve all been there. A user clicks a button, which needs to open a modal, which then needs to refresh a table, which also needs to show a notification. Do you make the button-click handler responsible for all of that? It’s a recipe for tightly coupled code that is difficult to test and reason about.
What if we could decouple these interactions? What if a button click could simply announce, “Hey, a ‘delete-listing’ action just happened!” and other parts of the application could independently decide if they care about that announcement? This is the core idea behind an event-driven system, and you can build a powerful one from scratch without a heavy framework. Let’s walk through how we built ours.
The Core Idea: A Central Event Bus
The heart of our system is a central event bus. It’s a single object responsible for listening to and dispatching events across the entire application. It doesn’t know or care what a “modal” or a “listing” is. Its only job is to let one part of the code broadcast a message that other parts can subscribe to.
Our implementation, which we call appEvents
, is surprisingly simple. Here’s a look at its core interface:
// The basic interface for our event bus
interface AppEvents {
emit<E extends keyof EventMap>(event: E, data: EventMap[E]): Promise<void>;
on<E extends keyof EventMap>(
event: E,
listener: (data: EventMap[E]) => void,
): string;
off<E extends keyof EventMap>(event: E, listenerId: string): void;
}
emit
: Broadcasts an event with a specific name and some data.on
: Subscribes to an event, running the listener function whenever the event is emitted. It returns a unique ID for the listener.off
: Unsubscribes a specific listener using its ID.
With this simple structure, any part of our code can talk to any other part without them being directly aware of each other.
import { appEvents } from "./app-events";
// Somewhere in our code, we emit an event
appEvents.emit("notification:show", {
type: "success",
message: "Listing updated!",
});
// In a completely separate module, we listen for it
appEvents.on("notification:show", (data) => {
// Logic to display the notification toast
console.log(`Showing a ${data.type} message: ${data.message}`);
});
This immediately cleans things up. The code that updated the listing doesn’t need to know how to show a notification; it just needs to announce its success.
Step 1: Modularize with Handlers
Now that we have a central bus, we can organize our logic into “handlers.” Each handler is a module responsible for a specific piece of the application’s functionality. We have a ModalHandler
, a ListingHandler
, a NotificationHandler
, and so on.
Each handler’s job is to listen for relevant events from the bus and perform its duties.
For example, the interface for our ModalHandler
looks something like this:
export interface ModalHandler {
openModal(modalId: string): void;
closeModal(modalId: string): void;
}
In our application’s entry point, we initialize this handler and subscribe it to the event bus.
// In homepage-handlers.ts, where we wire everything up
import { modalHandler } from "./handlers/modal-handler";
import { appEvents } from "./app-events";
appEvents.on("modal:open", ({ modalId }) => {
modalHandler.openModal(modalId);
});
appEvents.on("modal:close", ({ modalId }) => {
modalHandler.closeModal(modalId);
});
Now, any part of the application can open or close a modal just by emitting an event, with no direct dependency on the modal handler’s code.
Step 2: The Declarative DOM Action System
This is where the magic really happens. We wanted to avoid writing document.getElementById('my-button').addEventListener(...)
all over the place. Instead, we created a declarative system using simple data-action
attributes in our HTML.
<!-- A button to open a modal -->
<button data-action="open-modal" data-modal-id="createListingModal">
Create Listing
</button>
<!-- A button to delete a listing -->
<button data-action="delete-listing" data-listing-id="123">Delete</button>
This HTML is clean, readable, and clearly states its intent. But how does it work?
We use a single, global event listener that leverages event delegation. It listens for clicks on the entire document
, but it only acts on elements that have a data-action
attribute.
When it finds one, it doesn’t execute the logic directly. Instead, it fires off an event to our trusty event bus. A central DOMActionDispatcher
listens for these dom:action
events and routes them.
The Document-Level Event Listener Implementation
Here’s how we actually set up the document-level event listening in our dom-event-integration.ts
:
// Import our debounce utility
import { debounce } from './utils/debounce';
export class DOMEventIntegration {
private dependencies: DomDependencies;
private connected = false;
private boundHandlers = new Map<string, EventListener>();
private setupDOMEventListeners(): void {
const clickHandler = this.createDelegatedHandler(
"click",
(target, event) => {
if (target.dataset.action) {
this.handleDataAction(target, event);
}
},
);
// Debounce change handler for better performance
const debouncedChangeHandler = debounce(
(target: HTMLElement, event: Event) => {
if (target.dataset.action) {
this.handleDataAction(target, event);
} else if (target.matches('select[id$="Select"]')) {
this.handleFormChange(target, event);
} else if (target.closest("[data-seller-report-form]")) {
this.handleSellerReportFormChange(target, event);
}
},
150,
);
const changeHandler = this.createDelegatedHandler(
"change",
debouncedChangeHandler,
);
// Use passive listeners where possible for better performance
this.dependencies.document.addEventListener("click", clickHandler, {
passive: false,
});
this.dependencies.document.addEventListener("change", changeHandler, {
passive: true,
});
this.dependencies.document.addEventListener("keydown", keydownHandler, {
passive: true,
});
this.dependencies.document.addEventListener("submit", submitHandler, {
passive: false,
});
// Store handlers for cleanup
this.boundHandlers.set("click", clickHandler);
this.boundHandlers.set("change", changeHandler);
this.boundHandlers.set("keydown", keydownHandler);
this.boundHandlers.set("submit", submitHandler);
}
}
The key insight here is that we have just 4 document-level event listeners handling ALL events for the entire application. This is extremely efficient compared to attaching individual listeners to hundreds of elements.
The magic happens in our delegated handler creation:
private createDelegatedHandler(
eventType: string,
handler: (target: HTMLElement, event: Event) => void,
): EventListener {
return (event: Event) => {
const target = event.target as HTMLElement;
if (target) {
const actionElement = target.closest("[data-action]") as HTMLElement;
if (actionElement) {
handler(actionElement, event);
} else {
handler(target, event);
}
}
};
}
This pattern uses event bubbling; when you click on any element, the event bubbles up through the DOM tree until it reaches the document. Our listener catches it there and checks if the clicked element (or any of its parents) has a data-action
attribute.
The Debounce Implementation
Since we use debounced handlers for performance optimization, here’s the debounce utility function that makes this possible:
/**
* Debounce utility function
*
* Creates a debounced version of a function that delays execution until after
* a specified number of milliseconds have elapsed since its last invocation.
*/
function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
This debounce function works by:
- Storing a timeout reference: Each time the debounced function is called, we store the timeout ID
- Canceling previous timeouts: If the function is called again before the wait period expires, we cancel the previous timeout
- Delaying execution: Only after the specified wait time has passed without another call do we actually execute the original function with the latest arguments
This is particularly useful for change events on form inputs, where users might type rapidly or make multiple quick changes. Instead of processing every single keystroke or change, we wait for a pause in activity before responding.
Using Debounce in Practice
Notice how we use the debounce function in our event integration setup above. The debouncedChangeHandler
wraps our change handling logic with a 150ms delay. This means:
- If a user types rapidly in a form field, we don’t process each keystroke individually
- Only after they pause typing for 150ms do we actually handle the change event
- This reduces unnecessary API calls, DOM updates, and other expensive operations
Here’s what happens without debouncing:
// Without debounce - fires on every keystroke
user types "hello" → 5 events fired (h, e, l, l, o)
And with debouncing:
// With 150ms debounce - fires once after typing stops
user types "hello" → 1 event fired (hello) after 150ms pause
When an element with a data-action
attribute is found, we parse all its data attributes and emit an event:
private handleDataAction(target: HTMLElement, event: Event): void {
const action = target.dataset.action;
const actionData = this.parseDataAttributes(target);
appEvents.emitSync("dom:action", {
action,
element: target,
data: actionData,
} as any);
}
private parseDataAttributes(element: HTMLElement): Record<string, any> {
const data: Record<string, any> = {};
for (const attr of element.attributes) {
if (attr.name.startsWith("data-") && attr.name !== "data-action") {
const key = attr.name
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
data[key] = attr.value;
}
}
return data;
}
This approach gives us several benefits:
- Memory efficiency: Only 4 event listeners instead of potentially hundreds
- Dynamic content support: New elements added to the DOM automatically work without needing new listeners
- Performance: Debounced handlers prevent excessive function calls
- Clean separation: The DOM integration layer just emits events, it doesn’t know or care what happens next
// Simplified version of our dom-action-dispatcher.ts
// A map of action names to their handlers
const actionHandlers = new Map<string, Function>();
// Register a handler for the "open-modal" action
actionHandlers.set("open-modal", (element) => {
const modalId = element.dataset.modalId;
if (modalId) {
// Don't open the modal directly! Emit an event instead.
appEvents.emit("modal:open", { modalId });
}
});
// Register a handler for the "delete-listing" action
actionHandlers.set("delete-listing", (element) => {
const listingId = element.dataset.listingId;
if (listingId) {
appEvents.emit("listing:delete", { listingId });
}
});
// The dispatcher listens for the generic 'dom:action' event
appEvents.on("dom:action", ({ action, element }) => {
const handler = actionHandlers.get(action);
if (handler) {
handler(element);
}
});
Putting It All Together: The Full Flow
Let’s trace the journey of a single user click:
- The user clicks
<button data-action="delete-listing" data-listing-id="123">Delete</button>
. - Our single, global
click
listener catches the click. It sees thedata-action
attribute and emits a genericdom:action
event to the bus with the action name (delete-listing
) and the element itself. - The
DOMActionDispatcher
is listening fordom:action
. It receives the event, looks up"delete-listing"
in its map of handlers, and executes the corresponding function. - That function pulls the
listingId
from the element’sdata-*
attributes and emits a specific, high-levellisting:delete
event to the bus. - Our
ListingHandler
module, which we set up during initialization, is listening forlisting:delete
. It receives the event and runs the actual logic to delete the listing, perhaps by making an API call. - After the API call succeeds, the
ListingHandler
might emit yet another event, likenotification:show
, to inform the user. - The
NotificationHandler
hears this and displays a success message.
Notice the beautiful separation of concerns. The button doesn’t know about the listing handler. The dispatcher doesn’t know what deleting a listing entails. The listing handler doesn’t know how to show a notification. They are all decoupled, communicating only through the central event bus.
Why Bother? The Payoff
Building this system might seem like overkill for a small project, but the payoff is immense as an application grows.
- Maintainability: When you need to change how modals work, you only have to go to one place:
modal-handler.ts
. - Testability: Each handler can be unit tested in isolation. You can simply mock the
appEvents
bus and check if your handler emits the correct events or performs the right actions in response to incoming events. - Readability: The
data-action
attributes make the HTML’s behavior immediately obvious without having to hunt through JavaScript files. - Scalability: Adding new functionality is easy. You can create a new handler, register its listeners, and start emitting events for it without touching existing code.
By creating a clear, predictable pattern for how events flow through your application, you build a solid foundation that makes development faster, easier, and far more enjoyable.