Published on

Build Your Own Event Emitter

Authors
  • avatar
    Name
    Muyiwa Johnson
    Twitter
event-emitters

Introduction

As someone who loves building software, I'm always looking for patterns that make my code cleaner, more modular, and easier to maintain. One of the most powerful tools I've discovered? Event emitters.

At first, I used them without really understanding how they worked—just copying snippets from libraries or frameworks. But when I finally took the time to dig deeper, everything clicked. Event emitters became my go-to solution for managing communication between different parts of an application without creating messy dependencies.

Understanding Event Emitters

An event emitter is essentially a pub-sub (publish-subscribe) system where:

  • Components can subscribe to events
  • Other components can emit those events
  • The emitter handles notifying all subscribers

This creates beautiful decoupling between different parts of your application.

Building Our Own Event Emitter

Let's implement a type-safe event emitter in TypeScript:

// Define a generic type for the event map
type EventMap = Record<string, any>

// The EventEmitter class, generic over the event map
class NewEventEmitter<TEvents extends EventMap> {
  // Use a Map to store listeners: eventName -> Set of callbacks
  private listeners = new Map<keyof TEvents, Set<(data: any) => void>>()

  // Subscribe to an event
  subscribe<K extends keyof TEvents>(eventName: K, callback: (data: TEvents[K]) => void) {
    // Ensure a Set exists for this event
    if (!this.listeners.has(eventName)) {
      this.listeners.set(eventName, new Set())
    }
    // Add the callback to the Set
    this.listeners.get(eventName)?.add(callback)

    // Return an unsubscribe function for easy cleanup
    return () => this.unsubscribe(eventName, callback)
  }

  // Unsubscribe from an event
  unsubscribe<K extends keyof TEvents>(eventName: K, callback: (data: TEvents[K]) => void) {
    // Remove the callback from the Set for this event
    this.listeners.get(eventName)?.delete(callback)
  }

  // Emit an event, calling all subscribed listeners
  emit<K extends keyof TEvents>(eventName: K, data: TEvents[K]) {
    // Iterate over the Set of callbacks and execute each one
    this.listeners.get(eventName)?.forEach((callback) => {
      callback(data)
    })
  }

  // Note: A 'once' method could be added here as well,
  // similar to the previous implementation, by wrapping the callback
  // and calling unsubscribe within the wrapper.
}

This implementation uses a Map where keys are event names and values are Sets of callback functions. Using a Set automatically handles duplicate subscriptions and provides efficient addition/deletion. The subscribe method now conveniently returns an unsubscribe function.

How i have used Event Emitters

1. UI Component Communication

// Define our events
type ButtonEvents = {
  click: { x: number; y: number }
  hover: boolean
}

class Button {
  // Use the NewEventEmitter
  private emitter = new NewEventEmitter<ButtonEvents>()

  constructor(private element: HTMLElement) {
    element.addEventListener('click', (e) => {
      this.emitter.emit('click', {
        x: e.clientX,
        y: e.clientY,
      })
    })

    element.addEventListener('mouseenter', () => {
      this.emitter.emit('hover', true)
    })

    element.addEventListener('mouseleave', () => {
      this.emitter.emit('hover', false)
    })
  }

  // Use 'subscribe' instead of 'on'
  onClick(callback: (pos: { x: number; y: number }) => void) {
    return this.emitter.subscribe('click', callback)
  }

  onHover(callback: (isHovering: boolean) => void) {
    return this.emitter.subscribe('hover', callback)
  }
}

// Usage
const buttonElement = document.getElementById('my-btn')
if (buttonElement) {
  const button = new Button(buttonElement)
  const unsubscribeClick = button.onClick(({ x, y }) => {
    console.log(`Clicked at (${x}, ${y})`)
  })

  // Call the returned function to unsubscribe
  unsubscribeClick() // Clean it up
}

2. State Management (My Favorite Use Case)

type StoreEvents<T> = {
  change: T
  error: string // Example error event
}

class SimpleStore<T> {
  // Use NewEventEmitter
  private emitter = new NewEventEmitter<StoreEvents<T>>()
  private state: T

  constructor(initialState: T) {
    this.state = initialState
  }

  setState(newState: T) {
    this.state = newState
    this.emitter.emit('change', newState)
  }

  // The primary method is 'subscribe'
  subscribe(callback: (state: T) => void) {
    // Immediately call with current state
    callback(this.state)
    // Subscribe to future changes
    return this.emitter.subscribe('change', callback)
  }

  // Example method to emit an error
  setError(errorMessage: string) {
    this.emitter.emit('error', errorMessage)
  }

  // Method to subscribe specifically to errors
  onError(callback: (error: string) => void) {
    return this.emitter.subscribe('error', callback)
  }
}

// Usage
const store = new SimpleStore({ count: 0 })
const unsubscribeState = store.subscribe((state) => {
  console.log('State changed:', state)
})

const unsubscribeError = store.onError((error) => {
  console.error('Store error:', error)
})

store.setState({ count: 1 }) // Logs: "State changed: { count: 1 }"
store.setError('Something went wrong!') // Logs: "Store error: Something went wrong!"

// Later, cleanup subscriptions if needed
// unsubscribeState();
// unsubscribeError();

Why I Love This Pattern

  1. Decoupling: Components don't need to know about each other
  2. Flexibility: Easy to add new listeners without modifying existing code
  3. Type Safety: TypeScript prevents common mistakes
  4. Performance: Lightweight compared to many alternatives
  5. Debugging: Easy to track event flow through the system

Common Pitfalls to Avoid

PitfallSolution
Memory LeaksAlways call the unsubscribe function returned by subscribe
Too Many EventsKeep your event types focused and meaningful
Complex PayloadsKeep event data simple and structured

Nice Practices i picked up along the way

  1. Clean up subscriptions using the returned function:

    // React example
    useEffect(() => {
      const unsubscribe = emitter.subscribe('event', handler)
      // Cleanup function runs on unmount
      return () => unsubscribe()
    }, [])
    
  2. Use TypeScript with EventMap for self-documenting events.

  3. Keep events focused on specific actions or state changes.

  4. Document your event system for team members.

Conclusion

Event emitters have become one of my most trusted tools for building maintainable software. They're particularly useful when:

  • You need communication between decoupled components
  • You want to avoid complex dependency chains
  • You need a flexible system that can evolve over time

The TypeScript implementation we built provides excellent type safety while remaining lightweight and flexible. I encourage you to try building your own event emitter and see where it might simplify your codebase!