The useClass hook and using class-based APIs in modern React

October 10, 2021

Modern React has a problem, that functional components unfortunately, make it all too easy to rely on state that gets thrown out the window between renders. That's why useCallback and useEffect, which take closures that may accidentally reference old state in their bodies, can have weird bugs when dependencies aren't used correctly.

Take for example:

function MyComponent() {
    const [state1, setState2] = useState(5)
    const [state2, setState2] = useState(5)
    useEffect(() => { console.log(state1 + state2) }, [state1]); // oops state2 is also a dependency
    return <button onClick={() => setState1(state1 + 1)}>press me</button>
}

Because no dependencies have been listed for React to check between renders, it will see that the effect does not need to be updated, even though the value of state would have changed between renders when the button is clicked. When altering existing code especially, you may accidentally add a new dependency without realizing and not add it to the list, and thus, a bug is born.

This is really just a shortcoming of something that should be a language-level feature so that we wouldn't have to extract such redundant data ourselves, but to continue using vanilla JavaScript safely, we lucikly have the rules of hooks and some eslint plugins, which, when integrated properly with your editor and CI/CD, provide near perfect support for inferred dependencies.

These dependencies are checked by React using strict equality, which in JavaScript pretty much means object identity. To determine whether your useEffect, useMemo, or useCallback should be thrown out and replaced with the new closure, referencing up-to-date state, all objects in the dependency list are checked if they still === each other. This works fine, petty performance concerns aside, until you want React to not throw away the old function, but instead keep it stable.

Let's try the following:

function MyComponent() {
    const [isHovered, setIsHovered] = useState(false);

    class MyClass {
        x = 5
        color() { return isHovered? "red" : "blue" }
    }
    
    const [myInst, setMyInst] = useState(new MyClass())

    return <div onMouseEnter={() => setIsHovered(true)}
                onMouseLeave={() => setIsHovered(false)}
                style={{height: "50px", width: "50px"}} />
}

What's wrong here? Suppose we are using a dependency, "some-api", that manipulates subclasses from its own classes, and we have to register them to use them (ignore whether that's a well-designed JavaScript library or not for now, these kinds of packages do exist). Let's add an effect to register our class on component mount, and unregister it after:

import { ApiClass, register, unregister } from "some-api";

function MyComponent() {
    //...
    class MyClass extends ApiClass {
        x = 5
        color() { return isHovered ? "red" : "blue" }
    }
    useEffect(() => {
        register(MyClass);
        return () => unregister(MyClass);
    }, [MyClass]);
    //...
}

Now, a new MyClass is created every render because the class declaration is inside a function body, which means MyClass is always new so the effect is always reran, not just on mount, and we call register and unregister practically every render. Moreso, myInst is no longer of the same class after the first render, so myInst instanceof MyClass will be false, and that might mess with "some-api"'s assumptions.

Worse still, the MyClass.color method has an out-of-date copy of isHovered. myInst uses the definition as it saw it during the first render when it was constructed, and isHovered was immutably set as false. To fix this in JavaScript, we need a stable, mutable object indirectly referencing isHovered. In React we can get that using a ref like so:

import { ApiClass, register, unregister } from "some-api";

function MyComponent() {
    //...
    const componentState = useRef({});
    componentState.current.isHovered = isHovered; // update the state to whatever the lastest is from React
    class MyClass extends ApiClass {
        x = 5
        color() { return componentState.isHovered ? "red" : "blue" }
    }
    //...
}

Now our class will always reference the up-to-date state through an indirect reference that is always up to date. The final step is to make sure we have a stable reference to the class itself, which is done the same way we get a stable state object for indirect access of the component state.

import { ApiClass, register, unregister } from "some-api";

function MyComponent() {
    //...
    const MyClass = useRef(class MyClass extends ApiClass {
        x = 5
        color() { return componentState.isHovered ? "red" : "blue" }
    }).current;
    //...
}

useRef, will be a stable reference to its argument, and can only be mutated by setting its current property, but we've made that impossible by keeping no reference to the ref object itself. Now that we have it, we can generalize to create a useClass hook that does the work for us.

The useClass Hook

Smaller than I thought it would be, really.

function useClass<C extends new (...args: any[]) => any, S extends {}>(
  makeClass: (s: S) => C,
  dependencies: S = {} as S
): C {
  const stateRef = useRef({} as S).current;
  Object.assign(stateRef, dependencies);
  return useRef(makeClass(stateRef)).current;
}

Let's use it:

class Base {
  f() { return "x"; }
}

export default function App() {
  const [isHovered, setIsHovered] = useState(false);

  const Derived = useClass(
    (state) => class Derived extends Base {
                 f() { return `${super.f()}_${state.isHovered ? "y" : "n"}`; }
               },
    { isHovered }
  );

  const [myInst] = useState(new Derived());

  return (
    <div>
      <p onMouseEnter={() => setIsHovered(true)}
         onMouseLeave={() => setIsHovered(false)}>
        {myInst.f()}
      </p>
    </div>
  );
}

And there we have classes as usable as possible directly in our functional components. Probably not as performant as a module-scope class declaration unless our runtime is extremely clever, but that's just the world we're living in. If you're heavily using an inheritance based library in your work, maybe you'll enjoy this as much as I do.