Stop using arrow functions everywhere ...
therealnrf
Posted on November 2, 2021
Ever since their inclusion in JavaScript, arrow functions' usage has increased at a very rapid rate by JavaScript developers. Everyone seems to like arrow functions; perhaps more than they really should.
Firstly, there are situations where one absolutely should not use an arrow function.
Before going further, it is important to understand that arrow functions are not just a syntactical sugar for normal functions. Arrow functions, apart from having a different syntax for declaration, also behave differently than normal functions. One of the key differences is that they have no knowledge of the execution context. The execution context is dynamic (assuming your application has function calls) and is accessed through the
this
keyword in JavaScript. This is why it is often said that arrow functions do not have their own binding tothis
. They inheritthis
from their enclosing scope. This difference is at the heart of understanding when not to use arrow functions.window.id = 107; const obj = { id: 108, normalFunction: function () { console.log(this.id) }, arrowFunction: () => { console.log(this.id) }, } obj.normalFunction(); // output: 108 obj.arrowFunction(); // output: 107
The reason
arrowFunction()
logs 107 is that its declaration (as a method ofobj
) is in the global scope which, in the case of a browser environment, is thewindow
object. Sothis
resolves to window insidearrowFunction()
.
Do not use arrow functions ...
... when defining object methods
Using arrow functions to define object methods may result in unexpected (buggy?) behavior. Chances are that if you're defining an object method, you need access to to its properties from within that method.
const counter = {
count: 0,
currentCount: function () { return this.count },
incrementCount: function () { ++this.count }
}
counter.incrementCount();
console.log(counter.currentCount()); // output: 1
Everything is good and works as expected. But, if we had used arrow functions here:
const counter = {
count: 0,
currentCount: () => { return this.count },
incrementCount: () => { ++this.count }
}
counter.incrementCount(); // this does nothing unfortunately
console.log(counter.currentCount()); // output: NaN
this
resolves to the window
object in the above case and window.count
doesn't exist. If it is conciseness you're after, you can use the new shorthand syntax for defining object methods. The code below is equivalent to the first example above:
const counter = {
count: 0,
currentCount() { return this.count },
incrementCount() { ++this.count }
}
counter.incrementCount();
console.log(counter.currentCount()); // output: 1
... when defining prototype methods
This case is conceptually similar to the one discussed above. If you wish to access an instance property, you must not use an arrow function when defining the prototype method.
function Person(name) {
this.firstName = name;
}
Person.prototype.normalGreet = function () {
console.log(`Hi ${this.firstName}`);
}
Person.prototype.arrowGreet = () => {
console.log(`Hi ${this.firstName}`)
}
const person1 = new Person("Jack");
person1.normalGreet(); // output: "Hi Jack"
person1.arrowGreet(); // output: "Hi undefined"
As you can see, this.firstName
inside the normal function resolves successfully to "Jack"
while in the case of the arrow function it returns undefined
. This is because inside the arrow function this
points to the function's enclosing scope which is the window
object in this case. Since window.firstName
is undefined, it returns undefined
.
... when defining any function that depends on the dynamic context
The recurring theme is that arrow functions have no knowledge of the (dynamic) execution context and, hence, should not be used when the execution context is a factor. Another example of this would be callbacks that depend on the dynamic context like event handlers.
someButton.addEventListener("click", () => {
console.log(this.innerText);
})
In the code example above, clicking on someButton
will log undefined
since this
does not refer to the currentTarget
.
Furthermore, call
, apply
, and bind
do not work with arrow functions.
... when defining a constructor function
Arrow functions can't be used as a constructor function (can't use new
with them). It will simply raise an error.
const Person = (name) => {
this.firstName = name;
}
const person1 = new Person(); // TypeError: Person is not a constructor
... if you wish to use the special arguments
object
Just like with this
, arrow functions do not have their own arguments
object. Instead, arguments
inside an arrow function refers to the arguments of the enclosing scope.
function greet(name) {
const constructGreeting = (greeting) => {
return `${greeting} ${arguments[0]}`;
}
return constructGreeting("hello");
}
console.log(greet("Jack")); // output: "hello Jack"
As you can see, arguments[0]
inside the arrow function constructGreeting()
actually resolves to the value of name
which is the argument to greet()
(the function that encloses constructGreeting()
).
It's not all bad
There are scenarios where arrow functions provide benefit. Mostly by making the code cleaner/intuitive.
Async callbacks
Imagine you have async code but you still need access to the original context that called the async function (from within the async callback). Normal functions wouldn't work here (if you used this
inside the async callback). This issue was previously solved by using bind
or declaring a self
variable where self = this
. But since this
is already lexically bound in an arrow function, the arrow function solves this problem elegantly. An example should clear things up.
const obj = {
firstName: "Jack",
useName: function () {
setTimeout(function () {
console.log(`Hi ${this.firstName}`);
}, 1000);
}
}
obj.useName(); // output after 1 second: "Hi undefined"
In the example above, I've used setTimeout()
to simulate asynchrony. The callback function passed to setTimeout()
is called after 1 second of calling obj.useName()
. By the time the callback function is called, useName()
has long been executed. In fact, the callback queue is executed in the global context (window
in case of our browser environment). This means that this = window
when the async callback is called. This is why this.firstName
resolves to undefined
.
As I stated above, before arrow functions, this problem was solved by:
- using
bind
const obj = {
firstName: "Jack",
useName: function () {
setTimeout(function () {
console.log(`Hi ${this.firstName}`);
}.bind(this), 1000);
}
}
obj.useName(); // output after 1 second: "Hi Jack"
- by declaring a referencing variable (traditionally called
self
)
const obj = {
firstName: "Jack",
useName: function () {
let self = this;
setTimeout(function () {
console.log(`Hi ${self.firstName}`);
}, 1000);
}
}
obj.useName(); // output after 1 second: "Hi Jack"
Now we can simply use an arrow function which gives us the expected results.
const obj = {
firstName: "Jack",
useName: function () {
setTimeout(() => {
console.log(`Hi ${this.firstName}`);
}, 1000);
}
}
obj.useName();
Callbacks for iterator functions
When I say iterator functions I mean functions like map()
, reduce()
, and forEach()
that go through each item of an iterable and feed that item to a callback function. This is where arrow functions really shine. No need to worry about the dynamic context. Additionally, the compact syntax and the implicit return feature of arrow functions often result in code that is concise and more readable at the same time.
const myArray = [2, 4, 6, 8];
const newArray = myArray.map(number => number * 2);
console.log(newArray); // output: [4, 8, 12, 16]
In my humble opinion ...
Although arrow functions are a great addition to JavaScript, and in the proper context their concise syntax improves code readability, I still don't think that they should be the default for function declaration. If the situation doesn't specifically call for it, there is no need to use an arrow function.
Consider, for example, your globally-scoped or module-scoped functions (perhaps you have a helpers.js
file that has a bunch of helper functions your application needs). The arrow function syntax in this case simply looks more cluttered than a normal function declaration. On top of it, the code becomes even more cluttered if you have async
functions and need to export
some of them.
export async function foo() {
// function body
}
export const bar = async () => {
// function body
}
Using arrow functions:
export const firestore = firebase.firestore();
export const auth = firebase.auth();
const provider = new firebase.auth.GoogleAuthProvider();
export const signInWithGoogle = () => auth.signInWithPopup(provider);
export const getUserDocument = async (uid) => {
// body
}
export const createUserDocument = async (user, additionalData) => {
// body
}
Using normal functions:
export const firestore = firebase.firestore();
export const auth = firebase.auth();
const provider = new firebase.auth.GoogleAuthProvider();
export function signInWithGoogle() {
auth.signInWithPopup(provider);
}
export async function getUserDocument(uid) {
// body
}
export async function createUserDocument(user, additionalData) {
// body
}
The normal function syntax is just better. It reads better and it is actually less typing. Not that less typing should be a factor here but I've often observed that it is a factor behind using arrow functions.
Another example, which is slowly becoming a pet peeve of mine, is using arrow functions for declaring a React funtional component. There is just no need to do that! You can simply use a normal function declaration.
Using an arrow function:
export default const UserCard = (props) {
// a ReactJS functional component -- arrow edition
}
Using a normal function:
export default function UserCard(props) {
// a ReactJS functional component -- normal edition
}
Isn't a normal function delcaration much clearer?
👉🏻 Subscribe to my newsletter: click here
👉🏻 Follow me on twitter: click here
Posted on November 2, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024