Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes
Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes
What will I learn
- You will learn why class methods lose their
thiswhen 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
thisrules (ep22),bind(ep23), and the class material (ep38-43).
Difficulty
- Intermediate
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
- Learn JS Series (#29) - Memoization: Trading Memory for Speed with Closures
- Learn JS Series (#30) - Mini Project: A Small Functional Utility Library
- Learn JS Series (#31) - Object Literals, Shorthand, and Computed Property Names
- Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable
- Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
- Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
- Learn JS Series (#35) - The Prototype Chain: How Property Lookup Really Works
- Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance
- Learn JS Series (#37) - Constructor Functions and the new Operator, Step by Step
- Learn JS Series (#38) - The class Syntax: Sugar Over Prototypes, and What It Hides
- Learn JS Series (#39) - Class Inheritance: extends, super, and the Prototype Chain Again
- Learn JS Series (#40) - Static Members, Static Blocks, and Class-Level State
- Learn JS Series (#41) - Private Fields (#) and True Encapsulation
- Learn JS Series (#42) - instanceof, isPrototypeOf, and Checking Types Honestly
- Learn JS Series (#43) - Mixins: Sharing Behavior Without a Single Inheritance Line
- Learn JS Series (#44) - this in Classes: Method Binding Pitfalls and Fixes (this post)
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
- Create a class
Timerwith asecondsproperty (starting at zero) and atick()method that increments it and returns the new value. Detachtickinto a variable and call it -- observe thethisfailure. Then fix it with a constructorbindand show the detached call now works. - Rewrite the same
Timerusing an arrow-function class field fortick, and show that detaching it still works without any constructor binding. Explain in one sentence why the arrow keepsthis. - Given a class method that gets passed to
setTimeout, fix thethisproblem 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 becomesundefinedin 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:
bindthe 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
thisand can never lose it. Clean and modern, but per-instance and not on the prototype (no override, nosuper). - Fix 3: keep the method call intact by wrapping it in an arrow at the call site (
() => obj.method()), or use athisArgwhere 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.