I’ve met a lot of developers that don’t quite grok React.
They’re certainly good developers, often better than me. But every stack they’ve worked on before React has followed similar patterns: OOP, IOC containers — what I would consider “classic software design”.
The example I like to use is API architecture. Classic software design thinks of APIs as procedural in nature. That is, something you do in a series of steps.
Coming from this model, if you have to think of API requests reactively, like with React Query, it can be a bit of a mind-freak.
Before, or in spite of React Query, I’ve seen API abstractions implemented a bunch of different ways. At the end of the day they all approximated a “half baked” version of React Query. The same, but worse.
The most traditional variation is utilizing an “API Service”, which can be thought of as root-aggregate that holds all of the services. You see something a lot like this in Angular, generally controllers will DI a bunch of different “API services” to glue to the UI.
Doing things this way in React often has… costs.
Passing data between a root aggregate and React can be challenging. I have seen a tremendous amount of hacky shit to solve this problem including, but not limited to:
Using an incrementor to trigger a rerender:
const useMyApi = () => {
const [incrementor, setIncrementor] = useState(0)
const ref = useRef(new ApiService())
const fetchApi = async () => {
// Fetch data
await ref.current.fetchApi()
// Increment iterator to "force" a rerender
setIterator(x => x + 1)
}
return {
data: ref.current.apiData,
// Doesn't do anything, but causes the hook to rerender!
useless: iterator,
fetchApi
}
}
Recreating the entire class every render so that React picks up on the new object reference like DoorDash. Don’t be like DoorDash.
That being said there are certainly valid reasons to use classes in React. Often times enterprises have 100k+ LOC libraries full of their business logic. Rewriting those for React Query isn’t feasible. Regardless…
// view-model.ts
import { useRef, useSyncExternalStore } from "react";
// a generic listener called whenever state updates
export type Listener<T> = (t: T) => void;
abstract class ViewModel<State> {
private state: State;
constructor(defaultState: State) {
this.state = defaultState;
}
private listeners: Array<Listener<State>> = [];
public getState = () => {
return this.state;
};
protected setState(x: State) {
this.state = structuredClone(x);
this.broadcast(this.state);
}
private broadcast = (x: State) => {
this.listeners.forEach((fn) => fn(x));
};
public subscribe = (listener: Listener<State>) => {
this.listeners = [...this.listeners, listener];
return () => {
this.listeners = this.listeners.filter((x) => x !== listener);
};
};
}
// use-view-model.ts
// Our store implementation. Stores the VM as a ref and subscribes to it via useSyncExternalStore
// This requires the caller to store the VMs in state if multiple components are utilizing the same VM
export function useViewModel<VM extends ViewModel<any>>(
vm: VM
): [ReturnType<VM["getState"]>, VM] {
const vmRef = useRef(vm);
return [
useSyncExternalStore(vmRef.current.subscribe, vmRef.current.getState),
vmRef.current,
];
}
class RandomNumberService extends ViewModel<number> {
public randomNumberApi = async () => {
const response = await fetch("https://random.com/number")
return response.data
}
public getNumber = async () => {
// Handle loading state etc
const number = await randomNumberApi()
this.setState(number);
};
}
export const NumberComponent = () => {
const [number, vm] = useViewModel(new RandomNumberService(1));
return (
<button onClick={vm.getNumber}>
{number}
</button>
);
};
You can see just how easy this is. You can inject an entire view model into a useState-style hook with almost no boilerplate. Instead of a setter, you have access to the entire VM interface. And of course VMs can be shared and utilized in a global state pattern.
I ripped this pattern off of what I found to be the natural approach when learning SwiftUI. Because of Swift’s fine-grained control with @state and excellent getters and setters, I found storing my business logic in classes to feel very natural.
But do I recommend using classes in React? Not really. I’m sure this is an abomination to many. But sometimes it can be useful. Not all code is best described declaratively.