Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes

scipio(71)
Published in
#blog
Words
3243
Reading
15 min
Listen
Play
7h

Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes

js-banner.png

What will I learn

  • You will learn why class methods lose their this when detached, using the five rules from episode 22;
  • the three clean fixes: binding in the constructor, arrow-function class fields, and simply calling correctly;
  • the trade-offs of each fix, including a real memory consideration;
  • how this bug shows up with event handlers, timers, callbacks, and array methods;
  • a clear, practical recommendation for which fix to reach for and when.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-43 read, especially the this rules (ep22), bind (ep23), and the class material (ep38-43).

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes

Right, this is one I have been quietly promising you for a while now. All through the class episodes (ep38 onward) we have been writing methods that lean on this -- this.count, this.label, this.#listeners -- and every single time I have carefully called them as instance.method() so the examples just worked. But that carefulness was hiding something. The moment you stop calling a method that neat way -- the moment you pass it to setTimeout, hand it to addEventListener, drop it into array.map, or even just store it in a variable -- this can silently evaporate and your code blows up with a confusing error about reading a property of undefined. This is, without exaggeration, the single most common class bug in all of JavaScript. Beginners hit it, experienced people hit it, whole framework conventions exist just to route around it. Having said that, once you understand the why (which is nothing new -- it is just episode 22's rules applied to classes), the fixes are short and mechanical. Let's get into it. ;-)

Solutions to Episode 43 Exercises

Exercise 1 - object mixins:

const comparable = { equals(other) { return this.id === other.id; } };
const printable = { print() { return `#${this.id}: ${this.name}`; } };

const item = Object.assign({ id: 1, name: "widget" }, comparable, printable);
console.log(item.print());          // "#1: widget"
console.log(item.equals({ id: 1 })); // true
console.log(item.equals({ id: 2 })); // false

The insight: Object.assign copied both capabilities onto the object, which now has its own equals and print methods and can use them as if they had always belonged to it.

Exercise 2 - a Countable class mixin:

const Countable = (Base) => class extends Base {
  count = 0;
  increment() { this.count += 1; return this.count; }
};

class Base {}
const c = new (Countable(Base))();
console.log(c.increment()); // 1
console.log(c.increment()); // 2
console.log(c.increment()); // 3

The insight: the mixin returns a subclass that adds increment and a count field, layered onto the base through the prototype chain -- no multiple inheritance required.

Exercise 3 - stacked mixins versus composition:

const A = (B) => class extends B { a() { return "a"; } };
const C = (B) => class extends B { c() { return "c"; } };

class Root {}
const x = new (A(C(Root)))();
console.log(x.a(), x.c());        // "a" "c"
console.log(x instanceof Root);   // true

The insight: stacking blends both capabilities and keeps instanceof Root intact (because Root is genuinely on the chain), whereas plain composition -- a has-a property you delegate to -- keeps each capability in its own object and is much clearer about where a behaviour actually lives. One advantage of the mixin: methods land directly on the instance so you call x.a(). One advantage of composition: no silent method clashes and no scavenger hunt for a method's origin.

Now, on to the bug that bites everyone who ever writes a class: the disappearing this.

The problem: methods lose their this

Let me pull the golden rule from episode 22 right back to the front, because it explains everything that follows: this is decided by how a function is called, not by where it is defined. That is the whole ballgame. A class method is not some special magical thing that permanently knows which instance it belongs to. It is just a regular function that happens to live on the prototype (episode 34 and 35 hammered this home -- a class is sugar over prototypes). And a regular function's this gets wired up fresh on every single call, according to those five rules.

Rule 1 (the method-call rule) says: when you call obj.method(), this becomes obj. That is why all our examples worked -- we always kept the obj. in front. But rule 2 (the plain-call rule) says: when you call a function bare, as fn(), with nothing in front of the dot, this is undefined in strict mode. And here is the kicker you may not have internalised yet: class bodies are always strict mode (episode 38 mentioned this in passing). There is no sloppy-mode fallback to the global object inside a class -- it is undefined, full stop.

So watch what happens the instant we detach a method from its instance:

class Counter {
  constructor() { this.count = 0; }
  increment() {
    this.count += 1;    // this line ASSUMES 'this' is the instance
    return this.count;
  }
}

const counter = new Counter();
console.log(counter.increment()); // 1 - called as counter.increment(), rule 1, 'this' is counter
console.log(counter.increment()); // 2 - still fine

const detached = counter.increment; // grab JUST the function, no 'counter.' anymore
// console.log(detached()); // ERROR (runtime): Cannot read properties of undefined (reading 'count')

Look closely at that second-to-last line. counter.increment (with no parentheses) does not call the method -- it reads the function value out and stores it in detached. That stored function has completely forgotten it ever lived on counter. Functions in JavaScript do not carry their object around with them (they are not "bound" by default -- we saw this exact thing back in episode 23). So when you later call detached(), that is a plain call, rule 2 kicks in, this is undefined, and this.count throws because you cannot read .count off undefined.

This is the classic class bug, and the reason it is so nasty is that you almost never write const detached = counter.increment on purpose. It happens by accident, quietly, every time you hand a method to some other piece of code that will call it later:

class Button {
  constructor(label) { this.label = label; }
  handleClick() { return `${this.label} clicked`; }
}

const btn = new Button("Save");
console.log(btn.handleClick()); // "Save clicked" - fine, we called it as a method

// but now hand the method off to code that calls it LATER:
// setTimeout(btn.handleClick, 100);                 // BROKEN: 'this' is lost
// element.addEventListener("click", btn.handleClick); // BROKEN: same bug
// [btn].map(b => b).forEach(fn => btn.handleClick);   // easy to do by accident

Passing btn.handleClick hands over the naked function, detached from btn. When the timer fires or the click event lands, the browser (or Node) calls that function -- but it has no idea it was supposed to be a method on btn, so it calls it plainly, and this is not btn. Your handler either throws or, worse, silently does the wrong thing. Quite some late-night debugging sessions have started exactly here. So let's look at the three clean fixes, from the traditional one to the modern favourite.

Fix 1: bind in the constructor

The traditional, been-here-since-forever fix is to bind the method to the instance right in the constructor. Recall bind from episode 23: it returns a brand new function whose this is permanently locked to whatever you pass, no matter how that new function is later called. We store that bound function as an own property on the instance, which shadows the prototype method of the same name:

class Button {
  constructor(label) {
    this.label = label;
    this.handleClick = this.handleClick.bind(this); // lock 'this' to this instance, forever
  }
  handleClick() { return `${this.label} clicked`; }
}

const btn = new Button("Save");
const detached = btn.handleClick;   // detach it on purpose
console.log(detached());            // "Save clicked" - 'this' SURVIVED detachment
setTimeout(() => console.log(detached()), 0); // "Save clicked" - works in a callback too

Let me unpack that one dense constructor line, because it looks like it is chasing its own tail: this.handleClick = this.handleClick.bind(this). On the right, this.handleClick is looked up on the prototype (the normal method). We call .bind(this) on it, producing a new function glued to the current instance. On the left, we assign that new function to this.handleClick as an own property. From now on, property lookup finds the own bound version before it ever reaches the prototype (that is just the prototype chain doing its ordinary job, episode 35). No matter how btn.handleClick is called -- detached, in a timer, as an event listener -- this stays btn.

This works everywhere, it is explicit, and anyone reading the constructor sees exactly what is going on. The cost is twofold and worth being honest about. First, memory: every instance now carries its own bound copy of the method as an own property, so you lose the lovely shared-prototype efficiency where ten thousand buttons share one function. Second, boilerplate: one line of this.x = this.x.bind(this) per method that needs it, which gets tedious fast if a class has several such methods. For years this was simply the price of admission.

Fix 2: arrow-function class fields

The modern, hugely popular fix uses a class field holding an arrow function. Remember from episode 16 the defining trait of arrow functions: they have no this of their own. They inherit this lexically from the surrounding scope, permanently, and nothing -- not call, not apply, not being detached -- can change it. A class field initialises during construction, so an arrow field captures the freshly-created instance as its this and can never lose it:

class Button {
  constructor(label) { this.label = label; }

  handleClick = () => {          // an arrow-function CLASS FIELD, not a normal method
    return `${this.label} clicked`;
  };
}

const btn = new Button("Save");
const detached = btn.handleClick;
console.log(detached());         // "Save clicked" - the arrow kept 'this' lexically
setTimeout(btn.handleClick, 0);  // also fine - no wrapping needed at the call site

Here handleClick is not a method sitting on Button.prototype at all -- it is an instance field whose value happens to be an arrow function. Because the field initialiser runs during construction, with this being the instance, the arrow closes over that instance and freezes it as its this. Detach it, pass it anywhere, call it however -- this.label still resolves to the right button. No constructor boilerplate, no repeating the method name. This is why it became the default in a lot of class-based UI code (older React components leaned on this pattern heavily for their event handlers).

The trade-off is, honestly, the same shape as fix 1: an arrow field is a per-instance own property, not a shared prototype method, so you pay a little memory per instance. There is also a subtler consequence people forget: because the "method" lives on the instance and not on the prototype, it is NOT part of the normal method-dispatch machinery. A subclass cannot override it via the prototype in the usual way, and you cannot reach it with super from a subclass. For a leaf-level event handler that almost never matters. For a method that is genuinely part of an inheritance story, it can bite you -- so don't reach for arrow fields reflexively on every method.

Fix 3: just call it correctly (or wrap in an arrow)

The third "fix" is the one people overlook because it feels too simple: don't detach the method in the first place. Keep the instance.method() shape intact, so rule 1 keeps applying. The cleanest way to do that when you must pass something to a callback is to wrap the call in a tiny arrow at the point of use:

class Button {
  constructor(label) { this.label = label; }
  handleClick() { return `${this.label} clicked`; }   // plain prototype method, untouched
}
const btn = new Button("Save");

// wrap in an arrow: btn.handleClick() is still called AS A METHOD when the arrow runs
setTimeout(() => btn.handleClick(), 100);
[btn, new Button("Load")].forEach((b) => console.log(b.handleClick())); // "Save clicked" "Load clicked"
document?.addEventListener?.("click", () => btn.handleClick());          // also fine

The difference is small but decisive. Instead of passing btn.handleClick (the naked, detachment-prone function), you pass () => btn.handleClick() (an arrow that, when it eventually runs, calls the method with the dot still in front). So inside that arrow, btn.handleClick() is a rule-1 method call and this is correctly btn. And crucially, the method itself stays on the prototype -- shared across all instances, zero per-instance memory cost, fully overridable, super-reachable. You changed the call site, not the class. When you are the one writing the setTimeout or the forEach or the event registration, this is very often the least intrusive and cleanest option there is.

A concrete trap: array methods and their thisArg

Let me show you one more place this bug loves to hide, because it catches people who thought they understood it: array iteration methods. If you pass a class method straight into map, forEach, or filter, it is detached just like anything else -- and this inside it will not be your instance:

class Doubler {
  constructor(factor) { this.factor = factor; }
  scale(n) { return n * this.factor; }
}
const d = new Doubler(3);

// console.log([1, 2, 3].map(d.scale)); // BROKEN: 'this' is undefined inside scale

// fix A - wrap in an arrow (fix 3), keeps the method call intact:
console.log([1, 2, 3].map((n) => d.scale(n)));          // [3, 6, 9]

// fix B - some array methods take a 'thisArg' second argument that sets 'this' for you:
console.log([1, 2, 3].map(d.scale, d));                 // [3, 6, 9]

That second argument to map is the thisArg -- a little-known parameter that map, forEach, filter, some, and every all accept, which sets what this will be inside your callback. It is a perfectly valid third route in this specific case, though I reach for the arrow wrapper more often because it reads clearer and works with every callback-taking function, not just the array methods that happen to support thisArg. Nota bene: the fancy arrow-syntax array methods you write yourself ((n) => ...) never have this problem in the first place, precisely because arrows have no this to lose.

A quick look sideways: Python's bound methods

Many of you arrived here from the Learn Python Series, so a short comparison really sharpens what JavaScript is doing (and not doing) for you. In Python, when you access instance.method, you get back a bound method -- an object that already remembers both the function and the instance. Detaching it does not lose self:

class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1
        return self.count

counter = Counter()
detached = counter.increment   # this is a BOUND method - it remembers 'counter'
print(detached())              # 1  - works! Python bound the instance for you
print(detached())              # 2  - 'self' is still 'counter'

That is the whole difference in one example. Python binds self automatically at attribute-access time, so detached = counter.increment gives you something that already carries its instance. JavaScript does not do this -- counter.increment in JS is just the naked function, and you get to wire up this yourself at call time. Neither approach is objectively better (JavaScript's late this binding is exactly what makes call, apply, bind, and borrowing methods across objects so flexible), but if you are hopping between the two languages, this is a genuine gotcha. In Python you get binding for free; in JavaScript you ask for it explicitly, which is precisely what fixes 1 and 2 are doing by hand.

Which fix to use

Here is my honest, practical recommendation after quite some years of writing this stuff.

  • If you control the call site (you are writing the setTimeout, the loop, the event registration), prefer fix 3: wrap the call in an arrow, () => obj.method(). It keeps methods on the prototype (memory-efficient, overridable, super-friendly) and requires zero changes to the class. This is my default.
  • If you are handing a method reference to code you do not control -- a third-party library, a subscription system, an event bus that will call your function later and you have no wrapper opportunity -- and it simply must carry its own this, prefer fix 2: an arrow-function class field. It is clean, reliable, and needs no ceremony.
  • Use fix 1 (constructor bind) mainly in older codebases that already do it that way, or in the rare case where you specifically want a bound reference that is still, technically, a normal named method.

And one anti-pattern to actively avoid: do NOT reflexively convert every method into an arrow field "just to be safe". Most methods in most classes are only ever called as instance.method() and lose this never. Binding all of them wastes memory, breaks overriding, and clutters the class. Bind (or arrow-field) only the specific methods that genuinely get detached -- the handlers, the callbacks, the subscriptions. Everything else stays a plain prototype method.

Why arrows are wrong as ordinary methods (a reminder)

One consistency note tying straight back to episode 16, because it is the flip side of everything above. Arrow fields freeze this to the instance -- which is exactly what you want for a detachable handler, and exactly what you do NOT want for a method that is supposed to use normal dynamic dispatch. If you write all your regular methods as arrow fields out of habit, you quietly opt out of prototype-based overriding and super, and you pay memory per instance for no reason. So the rule is: use arrow fields deliberately, for the "must keep this" callbacks, and use regular prototype methods for everything else. Same keyword, this, two very different behaviours depending on which form you pick -- and now you know precisely when each one earns its place. ;-)

Try it yourself

  1. Create a class Timer with a seconds property (starting at zero) and a tick() method that increments it and returns the new value. Detach tick into a variable and call it -- observe the this failure. Then fix it with a constructor bind and show the detached call now works.
  2. Rewrite the same Timer using an arrow-function class field for tick, and show that detaching it still works without any constructor binding. Explain in one sentence why the arrow keeps this.
  3. Given a class method that gets passed to setTimeout, fix the this problem WITHOUT changing the class, by wrapping the call at the call site in an arrow. Then explain one concrete advantage this fix has over converting the method into an arrow field.

So what did we actually cover?

  • Class methods are regular prototype functions, so they lose this (it becomes undefined in strict mode, which class bodies always are) the moment they are detached from their instance -- the classic callback bug from timers, event handlers, and array methods.
  • Fix 1: bind the method to the instance in the constructor, creating a bound own property. Works everywhere and is explicit, but costs a per-instance copy and a line of boilerplate per method.
  • Fix 2: define the method as an arrow-function class field, which captures the instance's lexical this and can never lose it. Clean and modern, but per-instance and not on the prototype (no override, no super).
  • Fix 3: keep the method call intact by wrapping it in an arrow at the call site (() => obj.method()), or use a thisArg where an array method supports it -- preserving the shared prototype method.
  • Recommendation: fix 3 when you control the call site, fix 2 when handing a reference to external code, fix 1 mainly in legacy code -- and never arrow-field every method reflexively.

Next episode we shift from behaviour back to data, and survey the essential static Object methods -- assign, keys, values, entries, and fromEntries -- the everyday toolkit for slicing objects apart and putting them back together as plain data.

Thanks for reading, and see you in the next one!

scipio@scipio

Learn JS Series (#44) - this in Classes: Method Binding Pitfalls an... | Ecency