Of Classes and Arrow Functions (a cautionary tale)

Behold, the new hotness! The shapely Arrow Function has driven away the irksome function keyword and (by virtue of lexical this scoping) bought joy to many a JavaScript programmer. Yet, as the following account relates, even the best tools should be used with discretion.

A Hasty Refresher

Traditional function expressions create a function whose this value is dynamic and is either the object that calls it, or the global object¹ when there’s no explicit caller. Arrow function expressions, on the other hand, always assume the this value of the surrounding code.

let outerThis, tfeThis, afeThis;
let obj = {
  outer() {
    outerThis = this;

    traditionalFE = function() {tfeThis = this};
    traditionalFE();

    arrowFE = () => afeThis = this;
    arrowFE();
  }
}
obj.outer();

outerThis; // obj
tfeThis; // global
afeThis; // obj
outerThis === afeThis; // true

Arrow functions and classes

Given the arrow function’s no-nonsense approach to context, it’s tempting to use it as a substitute for methods in classes. Consider this simple class that suppresses all clicks within a given container and reports the DOM node whose click event was suppressed:

class ClickSuppresser {
  constructor(domNode) {
    this.container = domNode;
    this.initialize();
  }

  suppressClick(e) {
    e.preventDefault();
    e.stopPropagation();
    this.clickSuppressed(e);
  }

  clickSuppressed(e) {
    console.log('click suppressed on', e.target);
  }

  initialize() {
    this.container.addEventListener(
      'click', this.suppressClick.bind(this));
  }
}

This implementation uses ES6 method shorthand syntax. We have to bind the event listener to the current instance (line 18), otherwise the this value in suppressClick would be the container node.

Using arrow functions in place of method syntax eliminates the need to bind the handler:

class ClickSuppresser {
  constructor(domNode) {
    this.container = domNode;
    this.initialize();
  }

  suppressClick = e => {
    e.preventDefault();
    e.stopPropagation();
    this.clickSuppressed(e);
  }

  clickSuppressed = e => {
    console.log('click suppressed on', e.target);
  }

  initialize = () => {
    this.container.addEventListener(
      'click', this.suppressClick);
  }
}

Perfect!

But wait what’s this?

ClickSuppresser.prototype.suppressClick; // undefined
ClickSuppresser.prototype.clickSuppressed; // undefined
ClickSuppresser.prototype.initialize; // undefined

Why weren’t the functions added to the prototype?

It turns out the problem is not so much the arrow function itself, but how it gets there. Arrow functions aren’t methods, they’re anonymous function expressions, so the only way to add them to a class is by assignment to a property. And ES classes handle methods and properties in entirely different ways.

Methods get added to the class’s prototype which is where we want them — it means they’re only defined once, instead of once per instance. By contrast, class property syntax (which at the time of writing is an ES7 candidate proposal²) is just sugar for assigning the same properties to every instance. In effect, class properties work like this:

class ClickSuppresser {
  constructor(domNode) {

    this.suppressClick = e => {...}
    this.clickSuppressed = e => {...}
    this.initialize = e => {...}

    this.node = domNode;
    this.initialize();
  }
}

In other words our example code will redefine all three functions every time a new instance of ClickSuppresser is created.

const cs1 = new ClickSuppresser();
const cs2 = new ClickSuppresser();

cs1.suppressClick === cs2.suppressClick; // false
cs1.clickSuppressed === cs2.clickSuppressed; // false
cs1.initialize === cs2.initialize; // false

At best this is surprising and unintuitive, at worst needlessly inefficient. Either way it defeats the purpose of using a class or a shared prototype.

In which (sweet irony) arrow functions come to the rescue

Discouraged by this unexpected turn of events, our hero reverts to standard method syntax. But there’s still the gnarly matter of that bind function. Besides being relatively slow, bind creates an opaque wrapper that’s hard to debug.

Still, no dragon is unslayable. We can replace the bind from our earlier function with an arrow function.

initialize() {
  this.container.addEventListener(
    'click', e => this.suppressClick(e));
}

Why does this work? Since suppressClick is defined using regular method syntax, it will acquire the context of the instance that invoked it (this in the example above). And since arrow functions are lexically scoped, this will be the current instance of our class.

If you don’t want to have to look up the arguments each time, you can take advantage of the rest/spread operator:

initialize() {
  this.container.addEventListener(
    'click', (...args) => this.suppressClick(...args));
}

Wrap Up

I’ve never felt comfortable using arrow functions as stand-ins for class methods. Methods should be dynamically scoped according to the instance that calls them, but an arrow function is by definition statically scoped. As it turns out, the scoping issue is pre-empted by the equally problematic efficiency issue that comes from using properties to describe common functionality. Either way, you should think twice about using an arrow function as part of your class definition.

Moral: Arrow functions are great, but using the right tool for the job is better.

¹ undefined in strict mode
² https://github.com/jeffmo/es-class-static-properties-and-fields

5 thoughts on “Of Classes and Arrow Functions (a cautionary tale)

  1. In the code example that comes right after the sentence “In effect, class properties work like this”, the class properties should be normal functions instead of arrow functions. Otherwise, we would not need to bind() in the first code example in the section “Arrow functions and classes”.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s