DEV Community

Pascal Schilp
Pascal Schilp

Posted on

Events are the shit

Pardon my profanity, there's just no better way to say it. Events are just great. In this blog I'll showcase some cool things that you can achieve with just plain old events. You might not need an expensive or heavy library! Try an event.

Use EventTarget

Did you know you can instantiate EventTargets?

const target = new EventTarget();

target.dispatchEvent(new Event('foo'));
target.addEventListener('foo', (event) => {});
Enter fullscreen mode Exit fullscreen mode

Extend Event!

Did you know you can extend Event?

Instead of creating CustomEvents, you can just extend the Event class, and assign data to it, or even implement other methods on it:

// Create the event:
class MyEvent extends Event {
  constructor(data) {
    super('my-event', { bubbles: true, composed: true });
    this.data = data;
  }
}

const target = new EventTarget();

// Fire the event:
target.dispatchEvent(new MyEvent({foo: 'bar'}));

// Catch the event:
target.addEventListener('my-event', ({data}) => {
  console.log(data); // { foo: 'bar' }
});
Enter fullscreen mode Exit fullscreen mode

Extend EventTarget!

Did you know you can also extend EventTarget?

Here's how you can create a super minimal state manager using events:

class StateEvent extends Event {
  constructor(state = {}) {
    super('state-changed');
    this.state = state;
  }
}

export class State extends EventTarget {
  #state;

  constructor(initialState) {
    super();
    this.#state = initialState;
  }

  setState(state) {
    this.#state = typeof state === 'function' ? state(this.#state) : structuredClone(state);
    this.dispatchEvent(new StateEvent(this.#state));
  }

  getState() {
    return this.#state;
  }
}

export const state = new State({});
Enter fullscreen mode Exit fullscreen mode

And then you can use it like:

state.setState({foo: 'bar'}); // #state === {foo: 'bar'}
state.setState((old) => ({...old, bar: 'baz'})); // #state === {foo: 'bar', bar: 'baz'}

state.addEventListener('state-changed', ({state}) => {
  // Assign state, trigger a render, whatever
});

state.getState(); // {foo: 'bar', bar: 'baz'};
Enter fullscreen mode Exit fullscreen mode

I use this in my @thepassle/app-tools library, and it's often all the state management I need. Super tiny, but powerful state manager.

Events are sync

Did you know events execute synchronously?

const target = new EventTarget();

console.log('first');

target.addEventListener('foo', ({data}) => {
  console.log('second');
});

target.dispatchEvent(new Event('foo'));

console.log('third');
Enter fullscreen mode Exit fullscreen mode

Outputs:

// first
// second
// third
Enter fullscreen mode Exit fullscreen mode

Context-like patterns

It's a common scenario to pass down properties to child components. However, sometimes you end up in a situation known as "prop drilling", where you need to get some property down to a deeply nested child component, and along the way you're passing the property through components that really don't need to know about the property in the first place. In this case, it can sometimes be easier for the child component to request the property from a parent higher up the tree. This is also known as the context pattern. Since events execute synchronously, we can just use the following pattern:

class MyParent extends HTMLElement {
  theme = 'dark';

  constructor() {
    super();
    /**
     * The provider: 
     */
    this.addEventListener('theme-context', (event) => {
      event.theme = this.theme;
    });
  }
}

export class MyChild extends HTMLElement {
  connectedCallback() {
    const event = new Event('theme-context', {
      bubbles: true, 
      composed: true, 
    });
    this.dispatchEvent(event);

    /**
     * Because events execute synchronously, the callback for `'theme-context'`
     * event executes first, and assigns the `theme` to the `event`, which we
     * can then access in the child component
     */
    console.log(event.theme); // 'dark';
  } 
}
Enter fullscreen mode Exit fullscreen mode

Promise-carrying events

Did you know events can also carry promises? A great showcase of this pattern is the Pending Task Protocol by the Web Components Community Group. Now, "Pending Task Protocol" sounds very fancy, but really, it's just an event that carries a promise.

Consider the following example, we create a new PendingTaskEvent class:

class PendingTaskEvent extends Event {
  constructor(complete) {
    super('pending-task', {bubbles: true, composed: true});
    this.complete = complete;
  }
}
Enter fullscreen mode Exit fullscreen mode

And then in a child component, whenever we do some asynchronous work, we can send a new PendingTaskEvent to signal to any parents that a task is pending:

class ChildElement extends HTMLElement {
  async doWork() { /* ... */ }

  startWork() {
    const workComplete = this.doWork();
    this.dispatchEvent(new PendingTaskEvent(workComplete));
  }
}
Enter fullscreen mode Exit fullscreen mode

In our parent component we can then catch the event, and show/hide a loading state:

class ParentElement extends HTMLElement {
  #pendingTaskCount = 0;

  constructor() {
    super();
    this.addEventListener('pending-task', async (e) => {
      e.stopPropagation();
      if (++this.#pendingTaskCount === 1) {
        this.showSpinner();
      }
      await e.complete;
      if (--this.#pendingTaskCount === 0) {
        this.hideSpinner();
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
webreflection profile image
Andrea Giammarchi

Great examples but there's a common issue that keeps floating around these kind of examples/demos that inevitably end up in production causing possible disasters ... never use connectedCallback to add one-off listeners, or moved nodes (due React or any other library possibly changing the node position in the DOM) will add multiple times what should be a single listener.

Use handleEvent instead, if connectedCallback is really the place you need/want to add listeners:

class MyParent extends HTMLElement {
  theme = 'dark';

  connectedCallback() {
    /**
     * The provider: 
     */
    this.addEventListener('theme-context', this);
  }

  handleEvent(event) {
    event.theme = this.theme;
  }
}
Enter fullscreen mode Exit fullscreen mode

... or store the listener somewhere and remove it on disconnectedCallback!

Altenratively, and way cleaner, use the constructor to add listeners, making these available for both live and disconnected use cases without ever adding a listener more than once.

Collapse
 
thepassle profile image
Pascal Schilp

Fair point, this blog was admittedly a bit of a quick and dirty one, so I made the choice to create minimal code examples, and cut out "fluff" :)
Good of you to point this out though!

Collapse
 
samrocksc profile image
Sam Clark

:P The title made me laugh, the article didn't disappoint. totally a lot to consider here.

Collapse
 
trusktr profile image
Joe Pea

I love how that state example is like React state, but not coupled to a component system like React's state is coupled to its components. Decoupling is very good.