Update: After having angst about this for a good week or so (i.e. writing the post), I came across Dan Abramovâs well-articulated post on function components. Specifically I liked that he: a) directly talked about how obviously-similar FP and OO components are, and b) gave a very specific articulation of the goal FPâs are trying to solve, which is capturing a snapshot of the entire props+state during each render.
Iâm still skeptical that âhacking cross-render state back in to FP componentsâ vs. âjust use OO components with nice non-HOC/non-render prop âhooksâ-style APIsâ is necessarily the best choice, but I can at least now appreciate what theyâre trying to do.
And, personally, it is somewhat annoying that I found Danâs one post much more helpful than the several pages of the official React hooks/FP/etc. docs Iâd read, especially with their âtrigger wordsâ that âclasses suckâ, without a more thorough/detailed explanation (as Dan provided).
Hooks have made a big splash in the React ecosystem, and Iâd been procrastinating taking a look at them. But I finally got a chance and have what I think is a unique-ish way of thinking/modeling them, if only to help understand how they work/what their benefits are.
As an up-front disclaimer/bias, Iâve had a nagging suspicion that hooks are over-hyped, as some of their marketing material leads to a âraised eyebrow of suspicionâ, i.e. sections like Classes confuse both people and machines. So in this post, I may just be chasing a self-confirming bias.
That said, statements like these, for me, require a ton of empirical evidence to back-up, because after doing this awhile, Iâve personally seen classes usually win out as âactually a pretty intuitive way for most people to model/think about the worldâ.
I.e. when âOOP wonâ in the 90s (which I just missed), or (what I more directly witnessed) when âES6 classes wonâ in the 2010s, despite much proclamations of âprototypes are superiorâŚin theoryâ, and yet everyone had their own bespoke âadd classes to JavaScriptâ library in practice.
As a balance to this stated bias, I have become much more appreciative of FP (i.e. in many ways âFP has wonâ too), initially by working with Scalaâs collections and pattern matching, and more lately TypeScriptâs ADTs/etc. And Iâve started naturally preferring âjust data + transformationsâ for domain/entity data, vs. âeverything must be a classâ (see ts-proto).
So, I can see both pros/cons of the OOP/FP idioms and happily play both sides of the fence.
Anyway, back to the hooks.
What Are Hooks Good For?
Iâm going to assume general knowledge of hooks, and jump straight to the âoh I getâ raison detre:
Each [class] lifecycle method often contains a mix of unrelated logic. For example, components might perform some data fetching in componentDidMount and componentDidUpdate. However, the same componentDidMount method might also contain some unrelated logic that sets up event listeners, with cleanup performed in componentWillUnmount.
âŚ
To solve this, Hooks let you split one component into smaller functions based on what pieces are related (such as setting up a subscription or fetching data), rather than forcing a split based on lifecycle methods.
Ah, okay. That actually makes sense.
What theyâre saying is that a class like:
class MyComponent {
componentDidMount() {
// do thing 1
// do thing 2
}
componentWillUnmount() {
// undo thing 1
// undo thing 2
}
}
Is complicated because youâre organizing code by life cycle methods (âall mounts are in componentDidMount
, all unmounts are in componentWillUnmount
â) vs. organizing by abstraction/purpose (âall thing 1 codeâ is in one spot and âall thing 2 codeâ is in another).
Do We Need to Kill Classes?
So, just as a thought experiment, I wanted to balance âokay, I get the goal of a different axis of organizationâ vs. âgoing all-in on functional componentsâ.
When I read their problem, it makes me think that we want to give discrete sets of business logic (i.e hook-using code, the âthing 1â and âthing 2â from our example above) two things:
- Their own namespaced spot within the componentâs state
- Their own callbacks (âhooksâ) of the componentâs lifecycle.
So, letâs try and model that.
Recreating useEffect
in Classes
I think useEffect
is the easiest to recreate; we just want a list of functions to run as part of the lifecycle, i.e.:
class HookableComponent extends Component {
private effects: Array<Function> = [];
private lastEffects: Array<Function> = [];
public componentDidMount(): void {
this.cancelAndRunEffects();
}
public componentDidUpdate(): void {
this.cancelAndRunEffects();
}
public addEffect(effect: () => void): void {
this.effects.push(effect);
}
private cancelAndRunEffects(): void {
// If any effects returned functions, cancel them
this.lastEffects.forEach(e => e());
// Invoke the effects, and keep any return values that are functions
this.lastEffects = this.effects.map(e => e()).filter(e => e instanceof Function);
}
This âclass-based hookâ extension of Component
letâs us write our own (very naive, proof-of-concept) useEffect
:
function useEffect(
component: HookableComponent,
effect: () => void
): void {
component.addEffect(effect);
}
And now our usage is extremely similar; here is out-of-the-box with a FP component and React hook:
export function useFriendStatus(friendID: string): string {
...
useEffect(() => {
...
});
}
And here is our class-based approach:
export function useFriendStatus(
component: HookableComponent,
friendID: string
): string {
...
useEffect(component, () => {
...
});
}
Note that we have to pass component
, but Iâm going to gloss over that for now.
So, all things considered, this was really pretty trivial: our HookableComponent
just provides an API for snippets of code (useEffect
, useFriendStatus
, etc.) to subscribe to our lifecycle events.
In general, maintaining a list of callbacks like this is really pretty common in UI frameworks, so this is not terribly novel; although if anything what is novel is making addEffect
a public API, and exposing an âimplementation detailâ like our lifecycle to just any random snippet of code.
(Note that, part of the benefit of modeling hooks this way (could we do this class-based?) is precisely because they bring up interesting correlations like this: the FP-based useEffect
is effectively providing a public API for random snippets of code. Which is not bad, it is actually precisely the point that allows scattered (decoupled) logic to still coordinate around the componentâs lifecycle, and just interesting to think of it that way.)
Recreating the useState
in Classes
Making a class-friendly useState
is just a little more intricate.
My first pass at this tried to use the componentâs regular state
/setState
methods, just with âhiddenâ/unique-per-hook keys, but this would conflict with the componentâs actual state usage, particularly in the constructor when they do this.state = { ... }
.
So, instead I ended up adding a dedicated hookState
field:
class HookableComponent extends Component {
private nextHookId = 0;
// would probably not be public but this is proof-of-concept
public hookState: { [hookId: string]: any } = {};
public newHookId(): number {
return ++this.nextHookId;
}
}
Which when the class-based useState
secures itself a unique key in:
function useState<T>(
component: HookableComponent,
def?: T
): State<T> {
const hookId = component.newHookId();
const stateKey = `hook-${hookId}`;
if (def) {
component.hookState[stateKey] = def;
}
return {
get(): T {
return component.hookState[stateKey];
},
set(v: T): void {
component.hookState[stateKey] = v;
component.forceUpdate();
}
};
}
Similar to the class-based useEffect
, this function takes a reference to the component
.
In contrast to the FP-based useState
, the return value is slightly different, itâs a single State
interface, with two get
and set
methods, instead of the value
+ Setter
tuple.
This is because of the instantiation differences between my class-based hooks and the FP-based originals: I assumed mine would generally be instantiated outside of render
, i.e. in a constructor or field initialization, and these run just once. So, instead of returning the value itself, we need to return a handle to the value, than then can be invoked in render
, i.e. usage ends up looking like:
export class AppClass extends HookableComponent {
private status = useState(this);
public render() {
return <div> {this.status.get()} </div>
}
}
I dunno, personally I think a single State
return value with get()
and set()
methods is a more natural representation of whatâs happening, and it doesnât rely on the FP-based âthis useState
call happens to be invoked in the middle of (implicit FP componentâs) render
, so we can go ahead return the concrete valueâ nuance/implementation detail. But, all things considered itâs not a huge deal.
Whatâs also interesting about my tiny/naive re-implementation is that it immediately highlights why the FP hook rule of âyou must instantiate hooks in orderâ exists: because hooks donât have an identifiable, programmer-provided id, their âkey in the bag of hook-based stateâ is based on implicit execution order. If you reorder hook calls, both my naive implementation, and the real FP implementation, will assign different hook ids.
Other Effects
Iâve not had a chance to create the other effects use, i.e. useContext
, useCallback
, etc., but I imagine the approach would be similar: just make a more concrete representation of how the effect is mutating the component, have the base HookableComponent
keep the appropriate bookkeeping, and then invoke the effectâs lambda at the appropriate time.
Composing Class Hooks
Just like the FP hooks, our class-based hooks compose very naturally.
Here is the (simplified with timers and console.log
s) useFriendStatus
example from the hook tutorial:
export function useFriendStatus(friendID: string): string {
const [isOnline, setIsOnline] = useState('offline');
function handleStatusChange(status: string) {
setIsOnline(status);
}
useEffect(() => {
console.log('Subscribing');
const timer = setInterval(() => {
handleStatusChange(`${friendID} ${new Date().getTime().toString()}`);
}, 1000);
return () => {
console.log('Unsubscribing');
clearInterval(timer);
};
});
return isOnline;
}
And here is the class hook version:
export function useFriendStatus(
component: HookableComponent,
friendID: string
): State<string> {
const online = useState(component, 'offline');
function handleStatusChange(status: string) {
online.set(status);
}
useEffect(component, () => {
console.log('Subscribing');
const timer = setInterval(() => {
handleStatusChange(`${friendID} ${new Date().getTime().toString()}`);
}, 1000);
return () => {
console.log('Unsubscribing');
clearInterval(timer);
};
});
return online;
}
They are essentially identical.
Passing Around the Component
One big difference in the APIs between the FP React hooks and my naive spike is that Iâm currently passing around a component
parameter as the 1st argument to any hook method.
Honestly, I solely did this for expediency, as itâs quicker/easier to prototype by passing it around explicitly than figuring out an implicit global state trick (more discussion below). But itâs also an interesting design question.
Passing the component explicitly makes for a more verbose API, and it is admittedly boilerplate: yes, each hook call is going to need a component
, why should we repeat ourselves every single time?
Iâve gone back and forth on this issue over the years, as itâs an extremely common scenario when designing DSLs: a child piece of logic (here the hook) wants to know about the parent (here the class-based component, or in FP hooks the implicit FP component). So do you explicitly pass the parent, or somehow implicitly access it?
If you implicitly pass it, it means you store the parent in global state somewhere (usually in a stack to handle recursive instantiations), where the caller doesnât have to assign it, but once inside the child logic, the child logic can immediately grab it.
I.e. my naive class-based hooks could do something like this via:
class HookableComponent {
static currentComponent;
constructor() {
currentComponent = this;
// let sub class's constructor run, which means any
// hooks they create during their constructor can 'see' our
// static currentComponent value.
}
}
Now our hooks can have the same shorter API as Reactâs FP hooks:
function useState<T>(def: T) {
const component = HookableComponent.currentComponent;
// our existing code as usual
const hookId = component.newHookId();
}
Once you know this âis this parent passed explicitly or implicitlyâ pattern, it shows up in many DSLs.
I.e. here is the aws-cdk DSL for creating an AWS Lambda in CloudFormation:
class MyStack extends Stack {
constructor() {
super();
const api = new LambdaRestApi(this, "graphql-service", {
handler,
domainName: {
domainName: `graphql.${domainTld}`,
certificate: Certificate.fromCertificateArn(this, "cerf", certArn),
},
proxy: true,
});
}
}
The LambdaRestApi
child component needs to know what parent it is part of (the MyStack
instance), so we pass along the this
explicitly.
âŚactually a third way is to have add
methods in the parent component, i.e. something like:
class HookableComponent {
constructor() {
}
public addHook(hook: Hook) {
hook.setup(this);
}
}
// now in client code
class MyComponent extends HookableComponent {
constructor() {
this.addHook(useState(...));
}
}
So, useState
doesnât immediately create a hook, but instead waits just a little bit until addHook
pushes the parent
into it via a follow-up setup
call. But, anyway, just mentioning for exhaustiveness.
Which approach is better? Explicit or implicit?
Itâs hard to say.
When learning how things work, I prefer the explicit approach because it really highlights âah sure, this useState(component, ...)
is âattachingâ itself to component
as a side-effectâ. Without that hint, Iâm left wondering about how these two unrelated things, the component
and the useState
, are magically woven together. (Granted, itâs almost always a global variable, but still just makes me stop and think.)
However, admittedly when churning out 1000s of components, as Facebook engineers are wont to do, I could see the repetitiveness being a little annoying, and once you know how it works, having the bookkeeping done automatically can be enticing.
I think all things considered, Iâd probably keep explicit. Maybe.
Few Downsides of the Class-Based Hooks
Iâll explicitly call out a few downsides of my current HookableComponent
spike.
For one, it is based on inheritance, which I maintain âis not evil when used well/in-the-smallâ, but will mean the usual âoh rightâ OO-isms like if a child overrides componentDidMount()
and doesnât call super.componentDidMount()
, then none of our effect hooks will work (the useState
hooks would be fine in this scenario).
(Although if class-based hooks were built into React, they could invoke them in a safer/not-accidentally-override-able manner.)
I donât think we have a great way to mitigate this: TypeScript doesnât let us mark our componentDidMount
as final, nor are there other JavaScript/language-provided semantics (although there are probably ways of enforcing this at runtime, i.e putting âmarkerâ code in the base componentDidMount
and then failing at some later when we notice âhey wait, the marker didnât get runâ).
More generically, our API surface is generally more tied to implementation details of the HookableComponent
.
I.e. in FP hooks, especially with the implicit passing of the component, the calling code has no idea/coupling to how useState
works. Callers pass very little parameters, and can (within the rules), invoke it whenever/however they want. It is more declarative.
This really tiny API surface is enticing for React maintainers, as it means there would be less breaking changes as React needs to refactor things down the road.
âŚalthough, now that I think about it, with an implicit-passing API and âhidingâ the HookableComponent
implementation details (i.e. addEffect
, hookState
, etc.) directly into Component
as hidden implementation details (which is similar to how FP hooks are baked directly into FP components), the API surface may really not be that different.
So Are FP-Based or Class-Based Hooks Better?
This admittedly is a trick question, because I think it depends on your preferences/biases.
If youâve already decided you want FP-only components as an end-goal/true north (which, perhaps putting words in their mouth, but I think the React team has), and need to access the React state/lifecycle, etc., FP hooks look like a great way of doing that.
But, vice versa, if youâve decided you donât mind class-based components, I think Iâve shown (at least to myself) that you can achieve the very valid points of âdecoupling chunks of state/lifecycle logic from class method layoutâ with a few additions to the existing class-based component API, and not have to give up on classes.
I do have a somewhat pedantic quibble that I think the React Hook literature goes a bit too far in itâs anti-class push, by somewhat zealously pursuing FP even when its not really FP. I.e. hooks very explicitly have side-effects. They are mutating âsomethingâ. The ânot classâ/functional component is still there, just behind the scenes, effectively still creating a âcombination of state + behaviorâ (âŚsounds like a classâŚ), although not through the language-/spec-provided syntax, keywords, and semantics that we all already know, but through a DSL that is effectively recreating it.
Which at some point I worry that it becomes a leaky abstraction, and somewhat of a Greenspun-ism where theyâre trying really hard to not use classes; but when you invoke a functional component, but then have to provide side-effecting/global-accessing functions that are going to stitch back together what is effectively a class, you may not have something that is inherently a bug-ridden/half-implemented/etc. version of OOâŚbut is it really better?
Although, who knows, maybe it is.
I have my biases, and plan on sticking with class-based components indefinitely-ish, but the React team is paid to think about these issues full-time, across a huge React codebase within Facebook, and theyâre pretty smart. So, I should given them the benefit of the doubt (while also thinking critically/independently about their own biases/etc.).
And, even if Reactâs hooks are âmeh, something you can do with classesâ, pushing state-of-the-art thinking and implementations, and providing options, to approach something more FP is not bad either. Progress takes experimentation.
HmâŚWhy Not Support Both FP and Class Components?
Now that Iâve kicked the tires a bit, Iâm actually curious whether React could/should just support hooks in both component styles (i.e. using hooks from either FP-based or class-based components).
Right now hooks blow up if youâre not in a FP component (using that implicit global state as an indicator); but they could just as well use the same state to flip from FP-mode to class-mode, and add all of the same hooks theyâre adding to the FP component to the userâs class component (granted, with some caveats/compromises/contracts about how it interacts with any explicit logic the user has hand-coded in their own componentDidMount
/etc. methods).
And if you change my naive prototypes to use the implicit passing style, the useState
, useEffect
usage code is 100% the same (âŚalmost, see the next paragraph). I.e. the useFriendStatus
meta-hook still builds on the useState
and useEffect
hook primitives and none of them have to be any wiser about whether they were used in a FP-based component or a class-based component.
The biggest compromise (i.e. breaking change) would be the useState
return values changing from [value, setter]
to State
, to unlock/allow class-based components to instantiate their hooks outside of the render
cycle. Perhaps it is too late for such a breaking change, but it would be unfortunate if a âonly very slightly worseâ API for FP components (i.e. they need to call state.get()
now) would mean now an entire class of components (class-based) are locked out of hook-based APIs and business logic (which is the case today).
I dunno, it just seems like a boon for libraries (like Apollo, etc.) to have a single way of supporting all of their clients (FP-using and class-using), without the maintenance, documentation, and learning overhead of providing separate APIs for each style.
âŚgiven how easy I think this should be, I have to imagine this design consideration came up during the Hooks design process (i.e. âhey if we made a few small compromises, then class components could use hooks too, so why not?â), and they must have explicitly decided not to support hooks in class-based components.
That seems irksome if so, because it supports the somewhat-tin-foil-but-perhaps-not-really theory that Reactâs long term view is to deprecate class-based components all together. Which I donât want to immediately/knee-jerk dislikeâŚbut seems like it (throwing out class-based components) could another âHoCs are amazingâŚtwo years laterâŚoh waitâ type things.
Or, alternatively and more realistically, there is a lot more nuance to supporting hooks in both styles in a first-class/best-in-class manner.
Hacking Invitation
If youâd like to play with, spike, fork, add tests, pull request any of the âhooks-for-classesâ code, itâs currently here.
Post-Draft Thoughts
After writing this up, I did a few days of thinking and prototyping.
Iâm hooking up React Apollo in our app, so gave Higher-Order-Components a try. After reading this great post, I donât think HOCâs are that bad in theory. Instead, I blame the React Apollo HOC implementation specifically for trying to have too jank of âsometimes we flatten, sometimes we aliasâ semantics in which props it injects, which turns out yes is hard to type. Iâm tempted to think that just making the semantics simpler (not only to type but then also use), the backlash against HOC would not have been so bad. (That and also more widespread use the Subtract
type mentioned in that post, which as far as I understand is essentially required to make the HOC types work.)
I very briefly looked at porting the React Apollo useQuery
/ useMutation
FP hooks to my very-naive/WIP class-based versions, but that sucks, I donât want to be continually porting over every FP-based hook I want to use to my (again naive) class-based hook primitives, just to prove itâs possible. Even if itâs (ideally) very trivial due to how similar the mine-vs-theirs APIs are, they are not exact.
And, as I think about compromising and âfine, Iâll write FP-based componentsâ, it strikes me as annoying how similar they will be to OO components: theyâll have a bunch of local state (hooks) defined at the start (like OO fields) with a bunch of small functions (like methods) broken up to handle the rendering. They will essentially be isomorphic, or as a friend put it: âclasses vs functions is a misnomer - classes are just bundles of (organized) functions that close over mutable stateâ.
Which is exactly what FP components + hooks are reinventing.
That said, I think Iâm caving; fine, Iâll use FP components. Iâm honestly fairly resentful, given I like to use both OO and FP, where each is appropriate, but, even just while playing with React Apollo, itâs apparent that React really is effectively forcing everyone to use FP components by locking up all of the ânon-shitty ways of using librariesâ behind the âFP onlyâ hooks paradigm.
If this was purposeful, it was well-played. I seem to be the only one complaining âwtf hooks, no classes?â; the wider ecosystem seems to be thrilled with hooks, which having fought React Apolloâs HOC, I get.
Perhaps I am missing something about why hooks could not have been implemented into both class-based component and FP-based components; I wasnât paying enough attention at the time they were proposed/talked about/released to have watched for any design docs with âcould we support classes too?â rationale.