Let me start by saying what this article is not. This is not an essay about returning to class components — full stop.
React hooks solved real problems:
- No more binding
this - Rendering became properly declarative
- We could finally reuse UI logic without drowning in HOC soup
- We stopped writing 1000+ line God classes that mixed presentation with domain logic
But somewhere along the way, “class components are bad” quietly mutated into “classes and OOP in general are bad.” That’s a mistake.
In this essay, I argue that for greenfield, modern React apps of any meaningful complexity, the right architecture is functional components + hooks for the UI layer, combined with proper OOP (classes or objects) for rich domain and business logic.
Specifically, model your domain and business logic as objects (whether that’s a proper class or a plain object with methods), then hook those objects into React with useSyncExternalStore when you need to sync with the UI. Helper classes and registration singletons can still exist for supporting concerns, but the main value comes from giving your domain logic clear identity, invariants, and encapsulation.
I personally prefer the class syntax because it makes intent more explicit, but the deeper point is that OOP (classes or objects) often beats a purely functional approach when you have rich business rules to manage.
I’m going to show what this looks like in practice using Experiencer, the open-source WYSIWYG resume editor I built using React 16, and recently updated to 19. It mostly follows modern React conventions, but a few key structural classes make the codebase dramatically cleaner, more predictable, and easier to extend.
For many senior developers — especially those with full-stack or backend experience — this probably isn’t groundbreaking. It’s just a validation of what they’ve been quietly doing all along.
Link to Experiencer: https://github.com/vincentlaucsb/experiencer
Note: There are a few class components left which are largely left over from the original version, and will likely be migrated to functional components in future versions.
JavaScript is becoming more object-oriented, and your favorite libraries use classes
JavaScript has only gotten more object-oriented over time, not less.
Originally, JavaScript didn’t have classes at all. OOP was cobbled together with prototypes, constructor functions, and a lot of duct tape. It looked nothing like the clean OOP most people knew from C++, Java, or C#. Finally, the class keyword landed in ES6 (2015) and quickly became idiomatic, followed by private fields and methods (#private) in ES2022.
Look at the libraries you actually use every day. Most TanStack tools — including TanStack Query and TanStack Table — are thin React wrappers around framework-agnostic core classes or objects. The heavy lifting happens in solid, mutable objects with clear responsibilities. Zustand does something similar: it hides the store in a closure, creating a kind of lightweight OOP without the class keyword. The store object manages state, provides getters/setters, and handles subscriptions.
OOP is powerful because it allows you to directly encode the natural relationships between different variables and functions. With a well-designed class, you don't have to guess that a specific property (variable) is only for bookkeeping purposes and is not part of the public API: it is already declared. Pure functional programming leads to oddities like functions that take in multiple stateful parameters, as opposed to the OOP convention of referring to closely linked variables as part of this.
Why People Hate Classes: The God React Class Component
A "God class" is a common smell in OOP codebases. It is a class that is all-knowing and all-powerful. It stands in direct contradiction to numerous clean code principles, but most importantly the single responsibility principle--the idea a well designed class or function has one main responsibility.
Personal Confession: The Experiencer Resume God-Class
In the original Experiencer, the main Resume "component" was a true God class. It was responsible for rendering the split pane, binding hotkeys, loading and saving JSON, print preview, and a dozen other unrelated responsibilities.
My refactor focused on breaking the Resume component into several smaller hooks and functional components. If you want to see the extent of the damage I caused (and how I fixed it), here it is:
React class components didn't create this antipattern, but they made it easier to fall into. Personally, I deluded myself into thinking short methods meant I was following SRP, even when one class had 50 different responsibilities.
Having said that, I will show examples of how classes can be used appropriately in modern React after a quick history lesson.
Stores and useSyncExternalStore()
With the release of React 18 came a new hook useSyncExternalStore(). Here, the React team acknowledged the existence of external state systems and the need to interface with them.
This wasn't just an afterthought. The first implementation was useMutableSource(), and the goal of the hook was to allow "React components to safely and efficiently read from a mutable external source in Concurrent Mode", and it would do so by automatically scheduling updates "when the source is mutated".
This proposal saw the introduction of familiar getSnapshot and subscribe functions. However, these were described as able to be "declared in module scope" and were not tied to a class.
But the problem was the getSnapshot() function had to be stable, which practically meant it had to be memoized in most (if not all) cases, and the React Redux team raised concerns about this. It simply was not practical to write an ergonomic API with this requirement.
As a result of these discussions, we have the useSyncExternalStore() API we have today where the "store" itself is responsible for informing subscribers it has changed. Take a look at the official example from the React docs:
// This is an example of a third-party store
// that you might need to integrate with React.
// If your app is fully built with React,
// we recommend using React state instead.
let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];
let listeners = [];
export const todosStore = {
addTodo() {
todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
emitChange();
},
subscribe(listener) {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter(l => l !== listener);
};
},
getSnapshot() {
return todos;
}
};
function emitChange() {
for (let listener of listeners) {
listener();
}
}
While the example doesn’t use the class keyword, this is still object-oriented programming. The todosStore is a JavaScript object with a clear scope and related methods. The store manages its own state and notifies subscribers when something changes. Furthermore, JavaScript module semantics means the emitChange() helper function is hidden from outside view.
This pattern is exactly what useSyncExternalStore was designed for. Whether you implement the store as a plain object or as a class, you’re doing OOP. The important part is giving your domain logic clear identity and encapsulation.
For trivial cases like a simple todo list of strings, plain React state is often sufficient. But as soon as you need to sync with a database, handle complex caching, or manage real business rules, a dedicated object or class-based store (or a well-vetted library like TanStack Query) usually becomes the cleaner choice.
Case 1: Registration Singleton
Rule: Centralize fixed domain knowledge in a singleton or registry instead of scattering it across switch statements, magic strings, or duplicated logic.
In Experiencer there are a fixed number of resume node types (Section, Entry, Image, Markdown block, Page Break, etc.) — just like HTML has a fixed set of basic elements (<p>, <h1>, <ul>, etc.).
Instead of scattering that knowledge across the codebase in switch statements, magic strings, and duplicated logic, I created a single, globally available registry called ComponentTypes. It’s a class that knows everything about each node type: what component renders it, what its default props are, how it should behave in the tree, and more.
You can see the actual ComponentTypes.ts implementation here along with the simple helper function I wrote which registers every node through this singleton.
A lot of newer React developers would instinctively try to shove this into React state or context. That’s wrong.
This data doesn’t change in response to user actions. It’s not state — it’s the immutable rules of the system. Water can change physical state (liquid to gas), but it is always chemically H₂O. The set of possible resume node types is the same kind of constant.
By pulling this fixed domain knowledge into one clear registration object, the rest of the code becomes dramatically simpler. One source of truth, zero duplication, far less cognitive overhead.
This is exactly the kind of domain modeling where a small OOP-style object pays off hugely — even if the rest of the UI stays in hooks.
Factories (and Registries) in React
The ResumeComponent shows how this registry makes functional React code simpler.
A lot of people claim “you don’t need factories or OOP in React.” That’s missing the point.
Look at ResumeComponent.tsx. It’s a clean functional component that simply asks the registry “what should I render for this node type?” and gets back the right component + props. No giant switch statements. No duplicated logic. No hunting through files to figure out “how does a Section node work?” It's the classical OOP factory pattern applied to React.
The registry does the heavy lifting so the functional components don’t have to. This is OOP serving the functional layer — not fighting it.
More Concrete Benefits
One concrete benefit of centralizing node registration in the ComponentTypes singleton is how easily it enables context-aware UI. For example, the contextual toolbar — which changes its available actions based on the currently selected node — can be populated with a single call to the registry:
{
// Other toolbar buttons that apply to all nodes like copy/paste, delete, etc. go here
// Load node-specific toolbar options
...ComponentTypes.instance.toolbarOptions(selectedNode, props.updateSelected)
}
Case 2: Domain Model + Coordinator
Rule: Separate your business data and logic from your cross-cutting UI/UX concerns.
In Experiencer, the ResumeNodeTree class represents the entire contents of a resume. It owns the tree structure and provides methods like addChild, deleteChild, moveUp, and moveDown. It also maintains an internal index so any node can be retrieved in O(1) time by either its UUID or its hierarchical path.
This tree is then wrapped by NodeStore, which extends the generic ClassStore. This is a deliberate combination of inheritance and composition:
-
ResumeNodeTreestays pure: it only knows about tree structure and basic structural invariants. -
ClassStoreprovides the reusablegetSnapshot+subscribeinterface thatuseSyncExternalStoreexpects. -
NodeStoreadds the application-specific “dirty work” — mutation policy, integration with the rest of the app, and UI feedback.
The result is clean layering: the domain model stays focused and testable, while the store layer owns the policy and integration concerns. The store layer is closest to Fowler's service layer pattern, i.e. the part of a backend application that connects various data models to external callers. Callers don’t have to remember to do X before or after a mutation — the store enforces the invariants at the gate.
One recent refactor highlighted the value of this approach. I used to have scattered functions that called NodeStore.deleteNode() and then manually told the editor to unselect the node. When I asked an AI coding assistant to fold that logic into NodeStore, it immediately cried “SRP violation.” I pushed back: NodeStore is the deliberate “dirty work” layer between the pure domain model and the rest of the app. The AI agreed — the separation keeps the domain model clean while making the calling code simpler and safer.
This is how a complex OOP pattern, when executed with intention, reduces bugs in long-lived apps.
You could do all of this with useReducer and a pile of helper functions. The comparison below focuses on that boundary.
You may notice the OOP column is quite repetitive: ResumeNodeTree handles the pure model work, while NodeStore handles coordination and UI-facing concerns. That repetition is the point — it shows the pattern is predictable and easy to apply. You’re not performing a new architectural analysis for every individual concern.
| Goal / invariant | Functional approach (useReducer + helpers + middleware) | OOP approach (ResumeNodeTree + NodeStore) |
|---|---|---|
| Add node | Add logic often spans reducer branch + helper + middleware (validation/toast). Works, but the rule path is split. | Tree insertion stays in ResumeNodeTree; NodeStore.addNode() applies add rules and feedback in one call path. |
| Who calls the toast? | Usually middleware or caller code. It works, but ownership can be ambiguous unless the team is strict about conventions. |
NodeStore calls toast feedback as part of gated mutation flow, so ownership is explicit and centralized. |
| Delete node | Delete in reducer, then separately coordinate selection cleanup (caller, middleware, or extra reducer wiring). Easy to fragment over time. | Delete mechanics stay in ResumeNodeTree; NodeStore.deleteNode() also clears selection when needed. One obvious place to look. |
| Update node fields | Reducer action updates fields; batch rules usually require extra action types or orchestration helpers. |
NodeStore.updateNode() / updateNodeFields() keep update rules together while delegating pure data edits to ResumeNodeTree. |
| Move node up/down | Reorder logic plus boundary checks live across reducer branches and helpers. | Reorder logic lives in ResumeNodeTree; NodeStore exposes a small, stable mutation API. |
| Enforcing the write path | Team members must consistently use wrapped dispatch (not raw dispatch) to keep middleware rules active. |
UI code does not mutate ResumeNodeTree directly; writes go through NodeStore, so guardrails are consistently applied. |
| Undo/redo history | History capture is an extra concern layered onto reducer actions; easy to miss when adding new mutations. | History capture happens at NodeStore mutation entry points, so callers do less and forget less. |
| Overall mental model | Behavior is distributed: reducer files, middleware, helper modules, and caller discipline. | Clear split: pure model (ResumeNodeTree) + coordinating worker (NodeStore). Lower cognitive load for ongoing maintenance. |
The full class-based equivalent is split across two files — the pure domain model and the coordination layer — which together cover everything the functional version struggles to house cleanly: ResumeNodeTree.ts and NodeStore.ts.
The key difference is not whether both approaches can work; both can. The key difference is how much effort it takes to keep responsibilities clear. The OOP version makes the “home” for each concern obvious. The functional version invites endless debate about where things should live.
Case 3: Fluent API
**Rule:* Use fluent APIs to build composable object transformations, so complex behavior emerges from chaining small, predictable operations.*
Another area where classes shine in Experiencer is the CssNode. You can see the implementation here.
A CssNode is responsible for one thing and one thing only: storing and managing styling information for a particular selector. It is a recursive data structure, and its responsibilities flow naturally from that role:
- It can have children that are themselves
CssNodeinstances. - Each node has a
selectorthat is prepended when generating child rules (SASS-like behavior). -
copySkeleton()creates a structural copy without rules, useful for templating. -
setProperties()supports partial updates or full subtree replacements with an optional path parameter.
CssNode itself is wrapped by the same generic ClassStore adapter used by ResumeNodeTree. This is a nice example of composition: the domain logic stays focused and reusable, while the adapter provides frictionless useSyncExternalStore integration.
This class serves as the single source of truth for CSS throughout the app. It is serialized for the live resume preview, saved to JSON for persistence, and used as a core building block when generating templates.
Does this violate the Single Responsibility Principle? No. Every method exists to serve one purpose: making CssNode the authoritative representation of styling information. The fact that it can do many things with styles doesn’t make it a God object — it makes it a well-designed domain model.
What makes CssNode particularly powerful is its fluent API. At its core, a fluent API is nothing more than a series of methods that return this. Because it is a class, chaining becomes natural and discoverable:
randyCss
.mustFindNode('Section')
.setProperties({ 'margin-top': 'var(--spacing)' }, 'Content')
.setProperties(
{
'font-family': 'var(--sans-serif)',
'font-weight': 'bold',
'font-size': '20pt',
'color': 'var(--randy-teal)'
},
'Title'
)
.addNode('Grid', { 'grid-template-columns': 'var(--year-column-width) 1fr' }, 'resume-grid')
.addNode('Entry', {
'border-left': '1px solid var(--text-color)',
'padding-left': 'var(--large-spacing)'
}, 'resume-entry');
This pattern delivers three clear benefits:
- For developers: Type a dot (.) and the IDE immediately shows every available operation.
- For AI coding agents: The model doesn’t have to hunt through scattered functions — the available operations are explicitly declared on the object.
- For maintainability: The intent is obvious and the API is self-documenting.
In short, the fluent interface made possible by the class syntax turns a complex styling system into something predictable and pleasant to work with.
Case 3A: Adapters and Generic Programming
Rule: If your domain objects need to be modified so other parts of your app can understand it, consider using an adapter pattern instead.
Rule: If you ever feel the need to copy and paste classes or functions, with the only difference being the type of an input parameter, then make that parameter type generic.
A small but high-leverage object in Experiencer, that I have now mentioned twice, is the generic ClassStore.
It solves two recurring problems that would otherwise lead to duplicated code and subtle bugs:
- Stores need to notify React when their internal state changes, even when the change is not a simple reference update (e.g. deep mutations inside
ResumeNodeTreeorCssNode). - Both
ResumeNodeTreeandCssNodecontain serializable data for long-term persistence, so we need a reliableunsavedChangesflag.
Specifically, for the first bullet, we use a cheap version counter as a hack to force React to re-render. This hack is actually used by many popular libraries. This allows us inform React we want a re-render without paying the garbage collection tax of creating a bunch of shallow copies for the sake of changing one thing.
Rather than copy-paste these concerns into every store, we centralize them in ClassStore — the adapter that wraps any domain object and provides the exact getSnapshot + subscribe contract expected by useSyncExternalStore.
// Simplified shape of the generic adapter
abstract class ClassStore<T> {
private value: T;
protected version = 0; // forces React re-renders on deep mutations
private hasUnsavedChanges = false;
private listeners = new Set<() => void>();
constructor(initialValue: T) { this.value = initialValue; }
getSnapshot = () => ({ value: this.value, version: this.version });
subscribe = (listener: () => void) => { ... };
update(updater: (current: T) => T) {
this.value = updater(this.value); this.version++;
this.hasUnsavedChanges = true; this.listeners.forEach(l => l());
}
markSaved() {
this.hasUnsavedChanges = false;
}
}
You can read the full version here, but it's not that much longer. A simple adapter class allowed us to take any plain JavaScript domain model, like ResumeNodeTree or CssNodeTree, and quickly add persistence and Reactivity to them.
I chose to end my examples on this note, because powerful OOP patterns do not always mean writing a lot of code.
Benefits For Testing
Entire books have been written on this, so I will just give a quick summary. By separating useSyncExternalStore concerns away from domain concerns using ClassStore, this allows us to test our business rules using straightforward unit tests without mocking anything. Any mocking concerns thus can be contained in the ClassStore tests (or tests for the derived classes) instead of leaking everywhere.
AI Code Agents and OOP
It’s 2026, so let’s address the elephant in the room: AI code agents have made classic OOP more useful, not less.
If your domain is naturally a set of related data with closely associated behavior and rules, a class (or a well-structured object) is usually the natural fit. You can try to dance around it by writing a bunch of free-floating functions that each take a pile of state parameters, but all you’re really doing is recreating OOP poorly while pretending you’re not.
When you use AI assistance, you want the model to clearly understand “this data and these methods belong together.” You can achieve that through careful naming and comments in a purely functional style, or you can just create a class with private fields and call it a day.
Clear structure wins. Especially when the AI is helping you write the code.
Conclusion
Classes are just another tool — like compilers, linters, CI/CD pipelines, or package managers. The dogma that “classes are bad” is simply wrong.
True OOP, applied thoughtfully, gives you more than just organization. It enables:
- Better test-driven development: Pure domain models can be unit-tested in isolation with no mocks or React setup.
- Clearer performance characteristics: You control mutations instead of paying the garbage collection tax of naïve immutability.
- Lower long-term maintenance cost: Responsibilities have an obvious home, so onboarding and debugging become dramatically faster.
A purely functions-only approach in JavaScript often forces you to trade clarity for dogma. You end up with scattered logic, fragile invariants, painful stack traces, and higher cognitive load during code review and onboarding. The mental model becomes “where does this piece of state live and who is allowed to change it?” instead of “this object owns this behavior.”
Use classes (or plain objects) when they make the code clearer, easier to reason about, and easier to extend. Use hooks when they do the job better. Browse Experiencer’s code and you’ll see plenty of both — because the senior engineer’s job is to choose deliberately, not dogmatically.
JavaScript has been quietly improving its OOP support for years. The mature question isn’t “should we use classes?” It’s “where does OOP make the code simpler, safer, more testable, and more maintainable?”
So here’s the call to action:
Investigate and apply OOP where it fits — especially for rich domain logic. And take a hard look at the hidden costs of naïve immutability in functional JavaScript: the garbage collection tax, the scattered responsibilities, the fragile invariants, and the constant mental overhead of threading state. Sometimes the right tool for the job — even in 2026 React — is still a class.
Pragmatic engineering wins.

Top comments (0)