When Should You Use JavaScript Classes Over Functions?
The Problem
I was writing JavaScript code and kept seeing tutorials use classes everywhere. React components, Node.js services, utility modules - all wrapped in classes. But I also saw plenty of code that just used plain functions.
I got confused. Why add complexity with classes? Don’t functions handle everything?
I saw this in a Reddit thread:
“JS wasn’t originally designed around classes. Classes were added later mainly for cleaner syntax and familiarity for developers coming from OOP languages. You don’t need classes in JavaScript, they’re just a structured way to work with prototypes.”
So which is it? Should I use classes or functions?
What I Thought First
I started with functions because they’re simpler:
function createUser(name, email) { return { name: name, email: email, getName() { return this.name; }, getEmail() { return this.email; } };}
function createAdmin(name, email, permissions) { const user = createUser(name, email); return { ...user, permissions: permissions, hasPermission(perm) { return this.permissions.includes(perm); } };}
console.log(admin.getName()); // "Alice"console.log(admin.hasPermission('read')); // trueThis works fine for small scripts. But when I tried to check what type of object I had, I ran into problems:
console.log(admin instanceof Admin); // ReferenceError: Admin is not definedconsole.log(admin instanceof User); // ReferenceError: User is not definedconsole.log(admin.constructor.name); // "Object" - not helpfulThe factory function approach doesn’t give me type checking. In a large codebase, this becomes painful. I couldn’t reliably answer: “Is this object an Admin? Can I call hasPermission on it?”
What JavaScript Classes Actually Do
I realized that JavaScript classes are just syntactical sugar over prototypes. When I write:
class User { constructor(name, email) { this.name = name; this.email = email; } getName() { return this.name; } getEmail() { return this.email; }}JavaScript actually does this under the hood:
function User(name, email) { this.name = name; this.email = email;}User.prototype.getName = function() { return this.name; };User.prototype.getEmail = function() { return this.email; };The class syntax is cleaner and more familiar for developers coming from Java, C#, or Python. But it’s the same mechanism.
When Classes Actually Help
1. Runtime Type Checking with instanceof
The biggest practical benefit I found was type checking:
class User { constructor(name, email) { this.name = name; this.email = email; } getName() { return this.name; } getEmail() { return this.email; }}
class Admin extends User { constructor(name, email, permissions) { super(name, email); this.permissions = permissions; } hasPermission(perm) { return this.permissions.includes(perm); }}
console.log(admin instanceof Admin); // trueconsole.log(admin instanceof User); // trueconsole.log(admin instanceof Object); // trueThis lets me write defensive code:
function deleteUser(user) { if (!(user instanceof Admin)) { throw new Error('Only admins can delete users'); } // ... delete logic}2. Clear Inheritance Chain
With classes, inheritance is explicit:
class Animal { constructor(name) { this.name = name; } speak() { console.log(`${this.name} makes a sound`); }}
class Dog extends Animal { constructor(name, breed) { super(name); this.breed = breed; } speak() { console.log(`${this.name} barks!`); } fetch() { console.log(`${this.name} fetches the ball`); }}
const dog = new Dog('Buddy', 'Golden Retriever');dog.speak(); // "Buddy barks!"dog.fetch(); // "Buddy fetches the ball"The extends keyword makes the relationship clear. With factory functions, I had to manually compose objects, which got messy.
3. Required for Some Frameworks
This is where I hit a wall. I tried to create a custom element:
// This WORKSclass MyButton extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.shadowRoot.innerHTML = ` <button> <slot></slot> </button> `; }}customElements.define('my-button', MyButton);
// This DOES NOT WORK - custom elements require classesconst MyButton = { // ... factory function approach fails here};Custom Web Components require classes. React class components (legacy but still used) require classes. Some Node.js frameworks expect classes.
4. Better IDE Support
I noticed my IDE handles classes better:
class UserService { constructor(apiClient) { this.apiClient = apiClient; this.cache = new Map(); }
async getUser(id) { if (this.cache.has(id)) { return this.cache.get(id); } const user = await this.apiClient.fetch(`/users/${id}`); this.cache.set(id, user); return user; }
async updateUser(id, data) { const user = await this.apiClient.fetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }); this.cache.set(id, user); return user; }}My IDE (VS Code) can:
- Show all methods when I type
userService. - Jump to method definitions
- Refactor method names across files
- Show parameter hints
With factory functions spread across files, I lose some of this.
When Functions Are Better
I learned that classes aren’t always the answer. Here’s where I stick with functions:
Pure Utility Functions
// RIGHT: Simple function for simple taskfunction formatDate(date) { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });}
// WRONG: Class for simple taskclass DateFormatter { format(date) { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); }}No state, no inheritance - just a simple transformation. A function is cleaner.
Functional Programming Patterns
When I want immutability and composition:
// Function compositionconst pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const double = x => x * 2;const addOne = x => x + 1;const square = x => x * x;
const calculate = pipe(double, addOne, square);console.log(calculate(3)); // ((3 * 2) + 1) ^ 2 = 49Classes don’t fit this pattern well.
React Hooks
Modern React uses functions:
// RIGHT: Functional component with hooksfunction UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true);
useEffect(() => { fetchUser(userId).then(data => { setUser(data); setLoading(false); }); }, [userId]);
if (loading) return <div>Loading...</div>; return <div>{user.name}</div>;}
// WRONG (legacy): Class componentclass UserProfile extends React.Component { // ... more boilerplate}My Decision Framework
Now I use this mental model:
Use classes when:
- I need
instanceofchecks - I have shared state across multiple methods
- I’m building a domain model (User, Product, Order)
- Framework requires it (Web Components, some ORMs)
- I have a clear inheritance hierarchy
Use functions when:
- Simple input-output transformations
- Pure utility operations
- Functional programming style
- React functional components
- No state to track
A Real Example: API Client
Here’s how I refactored my API client code.
Before (mixed approach - confusing):
// Factory function but acting like a classfunction createApiClient(baseUrl) { let token = null;
return { setToken(t) { token = t; }, async get(path) { return fetch(baseUrl + path, { headers: { 'Authorization': `Bearer ${token}` } }); }, async post(path, data) { return fetch(baseUrl + path, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); } };}Problem: No instanceof check, shared state hidden in closure.
After (clean class approach):
class ApiClient { constructor(baseUrl) { this.baseUrl = baseUrl; this.token = null; }
setToken(token) { this.token = token; }
async get(path) { return fetch(this.baseUrl + path, { headers: this.#getHeaders() }); }
async post(path, data) { return fetch(this.baseUrl + path, { method: 'POST', headers: this.#getHeaders(), body: JSON.stringify(data) }); }
// Private method #getHeaders() { const headers = { 'Content-Type': 'application/json' }; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } return headers; }}
// Usageconst client = new ApiClient('https://api.example.com');client.setToken('my-secret-token');const data = await client.get('/users');Now I get:
instanceof ApiClientchecks- Clear shared state (
this.token) - Private methods (
#getHeaders) - IDE autocomplete
Common Mistakes I Made
Mistake 1: Classes for Everything
// WRONG: Class for simple utilityclass StringUtils { static capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } static lowercase(str) { return str.toLowerCase(); }}const result = StringUtils.capitalize('hello');
// RIGHT: Simple functionfunction capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1);}const result = capitalize('hello');Mistake 2: Deep Inheritance Hierarchies
// WRONG: Deep hierarchyclass Animal {}class Mammal extends Animal {}class Dog extends Mammal {}class GoldenRetriever extends Dog {}class ServiceGoldenRetriever extends GoldenRetriever {}
// RIGHT: Composition or shallow inheritanceclass Animal { constructor(traits = {}) { this.traits = traits; } hasTrait(trait) { return this.traits.includes(trait); }}Deep inheritance is a smell. Prefer composition over inheritance.
Mistake 3: Ignoring Prototypes
Even with classes, understanding prototypes helps debug:
class User { constructor(name) { this.name = name; } greet() { return `Hello, ${this.name}`; }}
const user = new User('Alice');
// These are equivalentconsole.log(user.greet()); // "Hello, Alice"console.log(User.prototype.greet.call(user)); // "Hello, Alice"
// Understanding this helps when debuggingconsole.log(user.hasOwnProperty('name')); // true (own property)console.log(user.hasOwnProperty('greet')); // false (on prototype)Summary
JavaScript classes provide structure, readability, and organization for complex applications. But they’re optional - they’re syntactical sugar over prototypes.
I use classes when:
- I need
instanceoffor runtime type checking - Multiple methods share the same state
- Frameworks require it (Web Components, some ORMs)
- The domain model has clear entities (User, Product, Order)
I use functions when:
- Simple input-output transformations
- Pure utility operations
- Functional programming style
- No shared state
The key insight from that Reddit thread was right: “You don’t need classes in JavaScript, they’re just a structured way to work with prototypes.” But structure matters in larger codebases, and classes provide exactly that.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 MDN: JavaScript Classes
- 👨💻 MDN: Inheritance and the prototype chain
- 👨💻 Reddit: What's the use of classes in JS
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments