Using Class Methods as Socket.IO Event Handlers with Correct Scope

Hey there,

As I continue to move forward with the multiplayer poker game, much of the in-game logic on both client- and server-side is now complete. I’m in the phase of cleaning up code, smoothing out functionality, and squashing πŸœπŸ›

While learning to us the Socket.IO library for client/server communications, there’s once thing I’ve noticed about its event handlers:

They don’t provide a way to specify an context (this) that the callback handlers should use.

Most of the time, it’s assumed the handler is used with an arrow function like this:

socket.on("event-name", (...args) => {
  // ...
});

Now, If I’m using this inside a class, the scope (context, or this) will reference the class, which is what I want.

Note: Socket.IO uses Node’s EventEmitter class for the server-side, while on the client-side, the component-emitter library is used. However, neither of these event libraries allow you to specify a context, like the way EventEmitter3 allows, which is also what Phaser 3 uses (and I’ve become spoiled by, heh 😏 )

But there’s another problem with this approach. Say you want to later turn off the event handler. You won’t be able to because there is no reference to the callback function. You could instead write something like this:

const callback = (...args) => {
  // ...
}

socket.on("event-type", callback);

// and then later...
socket.off("event-type", callback);

The issue here is that callback is a local function, and it’s eventually lost when it goes out of scope.

Even if the callback is a method of a class, this will reference the function, not the class. So not even this code will work as expected:

class SomeClass {
  ...
  public callback(): void {
    // ... 'this' will NOT reference this class instance
  }
  
  public setupSocket(socket): void {
    socket.on("event-type", someClass.callback);
  }
  
  public disposeSocket(socket): void {
    socket.off("event-type", someClass.callback);
  }
  ...
}

This is where the bind function comes in. This function allows you to specify what context the this will reference. In this case, we want it to reference the class instance itself.

class SomeClass {
  ...
  private boundCallback;
  
  public callback(): void {
    // ... now 'this' will reference this class instance
  }
  
  public setupSocket(socket): void {
    this.boundCallback = this.callback.bind(this);
    socket.on("event-type", someClass.callback);
  }
  
  public disposeSocket(socket): void {
    socket.off("event-type", this.boundCallback);
  }
  ...
}

Looking at the previous code, the first highlighted line shows how a bound callback was created, by wrapping the original callback method, and making the this reference the class as expected.

Buuuuuuut!

There’s an issue with this approach as well! πŸ™„

What is we want to setup another socket with this class? Each call to setupSocket to overwrite the previous boundCallback reference. It would be nice for each bound callback to be mapped to the corresponding socket. That, plus it’d be nice to not have to litter the code with a bunch of bind calls or get static classes or methods involved…

EventsManager Class

Let’s introduce an EventsManager class ⭐

This will allow us to gracefully solve all the issues at once:

  • Specify a context of our choice
  • Keep references to bound callbacks
  • Allows adding and removing of handlers
  • Keeps code nice and tidy
type Callback = (...args: unknown[]) => void;

interface IEventData {
  // the callback that will be bound to the original callback meth of the EventsManager class
  boundCallback: Callback;

  // reference to the user's custom callback
  userCallback: Callback;

  // the event type
  id: string;

  // specifies if the event should only fire once
  isOnce: boolean;

  // what `this` should point to
  context: unknown;
}

class EventsManager {
  private eventsDataById: Map<string, IEventData[]>;
  private socket;
  
  constructor(socket) {
    this.eventsDataById = new Map();
    this.socket = socket;
  }
}

A few new things here. πŸ˜‰

Note: The pseudocode is influenced by Typescript, such as type aliases and interfaces, but you can make the same thing in JavaScript.

The Callback type is just a shortcut for specifying callback handler functions.

The IEventData interface is a template that we’ll use to define plain objects that should have all the specified properties.

The eventsDataById property is a Map of key/value pairs. Each key is a string (the event id type), and the data is an array of IEventData objects. This will make it possible to assign (and keep track of) multiple handlers to the same event type.

We’ll also be specifying event frequency (one-time events vs continual events).

But this code alone doesn’t allow us to do anything practical yet. Let’s add our own version of the on method, the event emitter de facto standard if adding a handler.

The EventsManager.on Method
on(eventId: string, callback: Callback, context: unknown, isOnce?: boolean): void {
  // get the array of events handler data based on the specified event id. if it doesn't
  // exist, create an empty array and add it
  let eventsData = this.eventsDataById.get(eventId);
  if (!eventsData) {
    eventsData = [];
    this.eventsDataById.set(eventId, eventsData);
  }

  // create event handler data for this new event being added
  // also create the bound callback, wrapping around the original callback method
  const eventData: IEventData = {
    // when binding the callback function, pass the event id. this will allow us to
    // reference the event data in the callback to access the user callback and
    // context
    boundCallback: this.callback.bind(this, eventId),
    userCallback: callback,
    id: eventId,
    context,
    isOnce,
  };

  // add the new event handler data to the array (the array is stored in the map)
  eventsData.push(eventData);

  // specify the BOUND callback in the socket
  this.socket.on(eventId, eventData.boundCallback);
}

In the above code, we’re adding the event handler onto the socket as usual. However, its callback will point to the newly bound callback. But how do we call our own callback function that was specified in the on method? πŸ€”

And, how do we use the context that was also specified? πŸ€”

Well, the best part happens in the callback method itself…

To be continue in part 2…!

Nah, I’m just kidding, read on to find out!

The EventsHandler.callback Method
private callback(eventId: string, ...args: unknown[]): void {
  const eventsData = this.eventsDataById.get(eventId);

  let length = eventsData.length;
  let index = 0;

  while (index < length) {
    const eventData = eventsData[index];
    if (eventData.isOnce) {
      // if this is a one-time event handler, remove it
      this._eventTarget.off(eventId, eventData.boundCallback);
      eventsData.splice(index, 1);
      length -= 1;
    } else {
      index += 1;
    }

    eventData.userCallback.call(eventData.context, ...args, this);
  }

  if (length === 0) {
    // it's possible that removing the one-time handler caused the eventsData array
    // to become empty. if so, we can remove it from the map. it's not strictly
    // necessary, but it helps to keep things clean ^_^
    this.eventsDataById.delete(eventId);
  }
}

All events that we’re listening for will call this callback function. Since it’s bound to this class, this will point to our class instance of EventsManager.

When a socket event handler fires this function, it’ll loop through all the event handlers that we attached onto it, and call them in the order they were added. Any one-time handlers are removed after they’re called.

Let’s have a look at the first line, the function signature:

private callback(eventId: string, …args: unknown[]): void...

You’ll see the eventId is the first argument. This is the argument we specified when we created the bound callback in line 16 of the on method. Here, it’s followed by an ...args parameter. This is for any parameters that are passed in by the socket handler.

Now, have a look at line 18. This is where the user’s callback is called, with context they wanted. The call method is another method of the Function object, like bind. The call method allows invokes a function while allowing us to specify a particular context on it – in our case, the one specified in the on method, and later saved in the IEventData object.

call also allows us to specify any parameters. Here, we want pass along the arguments received from the socket’s handler.

The EventsHandler.off Method

Since we can listen for events with the on method, we need a way to un-listen to them. We’ll make our own off method for this.

off(eventId: string, callback: Callback, context?: unknown, isOnce?: boolean): void {
  // get the specified events data array based on the event id. if not found,
  // exit early
  const eventsData = this.eventsDataById.get(eventId);
  if (!eventsData) {
    return;
  }

  for (let index = eventsData.length - 1; index >= 0; index -= 1) {
    const eventData = eventsData[index];

    // if removing handlers by callback, check that it matches. if not, skip
    if (callback !== undefined && callback !== eventData.userCallback) {
      continue;
    }

    // if removing handlers by context, check that it matches. if not, skip
    if (context !== undefined && context !== eventData.context) {
      continue;
    }

    // if removing handlers by frequency, check that it matches. if not, skip
    if (isOnce !== undefined && isOnce !== eventData.isOnce) {
      continue;
    }

    // turn the event handler off in the socket, referencing our bound callback
    this.socket.off(eventId, eventData.boundCallback);

    // remove this events data object from the array
    eventsData.splice(index, 1);
  }

  if (eventsData.length === 0) {
    this.eventsDataById.delete(eventId);
  }
}

In this code, we first need to get access to the events data array, based on the event id. However, unlike the on method, the array must exist before continuing.

When removing event listeners, it will optionally only remove listeners that have a specific callback, context, or frequency.

Finally, if this were to be used in your application, you could do something simple like this:

class YourClass {
  private socketEvents: EventsManager;

  constructor(socket) {
    this.socketEvents = new EventsManager(socket);
  }
  
  public foo() {
    socketEvents.on(eventId, this.onSocketEvent, this);
  }

  private onSocketEvent(args) {
    // do stuff here
  }
}

That’s pretty much it for a bare-bones version of this code.

You could do more to it, like add a removeAll method, which completely removes all event handlers – excellent for cleaning up the EventsManager class when you’re done. You could also build on this to handler server-side as well as client-side events, as they all use on, off, and once methods. But this is a very short class that makes event handling using sockets a helluva lot easier, IMO. 😎

And I continue onward with the poker game! I’ll keep you updated as I make major milestones. Though I’ll admit, client work has got me super-busy these days, but continue to stay tuned in. I appreciate your patience! πŸ‘πŸΎ

– C. out.

2 Replies to “Using Class Methods as Socket.IO Event Handlers with Correct Scope”

  1. Are you planning to share your code for those who are learning Phaser?

    I’d appreciate!

    1. Hey Yuriy,

      I do plan on doing so.
      Currently, the poker game will eventually be a trio of GitHub repos: one for the server, one for the client, and one for common code shared among both.

      Right now, it’s still a bit of a mess under the hood.

      Also, thanks for the sub!

      – C.

Comments are closed.