Mixing React Hooks with classes or making functional components more structured


Hook inside a class*

Relatively not so long ago Hooks were introduced in React. They allow us to have decoupled and reusable logic for components state and effects, and make dependency injection easier. But API wise it looks a bit like a step back from class-based components to sort of jQuery territory with tons of nested functions.

So I thought that it might be nice to try to mix both approaches.

TLDR: it’s possible to make it nice and declarative, but it requires metaprogramming and wouldn’t work in some browsers.

The hookable class adventure

Let’s assume that we have a simple counter component:

export default ({ initialCount }) => {
  const theme = useContext(Theme);

  const [current, setCurrent] = useState(initialCount);
  const [clicked, setClicked] = useState();

  const onClick = useCallback(() => {
    setCurrent(current + 1);
    setClicked(true);
  }, [current]);

  useEffect(() => {
    console.log("did mount");

    return () => {
      console.log("did unmount");
    };
  });

  return (
    <div>
      <p>
        Value: <span style=>{current}</span>
      </p>
      {clicked && <p>You already clicked!</p>}
      <p>Initial value: {initialCount}</p>
      <button onClick={onClick}>Increase</button>
    </div>
  );
};

As a first step towards classes, I made a simple decorator, that creates a function that initializes a class and calls render (the only similarity with original class-based components interfaces) method.

const asFunctional = cls => props => new cls(props).render();

As we can initialize attributes on a class body, it’s already safe to move useContext to it:

class Counter {
  theme = useContext(Theme);

  constructor({ initialCount }) {
    this.initialCount = initialCount;
  }

  render() {
    const [current, setCurrent] = useState(this.initialCount);
    const [clicked, setClicked] = useState();

    const onClick = useCallback(() => {
      setCurrent(current + 1);
      setClicked(true);
    }, [current]);

    useEffect(() => {
      console.log("did mount");

      return () => {
        console.log("did unmount");
      };
    });

    return (
      <div>
        <p>
          Value: <span style=>{current}</span>
        </p>
        {clicked && <p>You already clicked!</p>}
        <p>Initial value: {this.initialCount}</p>
        <button onClick={onClick}>Increase</button>
      </div>
    );
  }
}

export default asFunctional(Counter);

Manual assignment of props as attributes looks ugly, and useState inside render is even worse. It would be nice to be able to declare them on the class body. Unfortunately, decorators that can help with that aren’t here yet, but we can use a bit of Proxy magic by making a base class that will intercept attributes assignment and inject values for props and descriptors for the state:

const prop = () => ({ __isPropGetter: true });

const asDescriptor = ([val, setVal]) => ({
  __isDescriptor: true,
  get: () => val,
  set: newVal => setVal(newVal),
});

const Hookable = function(props) {
  return new Proxy(this, {
    set: (obj, name, val) => {
      if (val && val.__isPropGetter) {
        obj[name] = props[name];
      } else if (val && val.__isDescriptor) {
        Object.defineProperty(obj, name, val);
      } else {
        obj[name] = val;
      }
      return true;
    },
  });
};

So now we can have descriptors for the state, and when a state attribute value will be changed, set... will be called automatically. As we don’t need to have the state in a closure, it’s safe to move onClick callback to the class body:

class Counter extends Hookable {
  initialCount = prop();

  theme = useContext(Theme);

  current = asDescriptor(useState(this.initialCount));
  clicked = asDescriptor(useState());

  onClick = useCallback(() => {
    this.current += 1;
    this.clicked = true;
  }, [this.current]);

  render() {
    useEffect(() => {
      console.log("did mount");

      return () => {
        console.log("did unmount");
      };
    });

    return (
      <div>
        <p>
          Value: <span style=>{this.current}</span>
        </p>
        {this.clicked && <p>You already clicked!</p>}
        <p>Initial value: {this.initialCount}</p>
        <button onClick={this.onClick}>Increase</button>
      </div>
    );
  }
}

export default asFunctional(Counter);

The only not so fancy part left is useEffect inside render. In Python world similar problem with context managers API solved by contextmanager decorator, that transforms generators to context managers. I tried the same approach with effects:

const fromGenerator = (hook, genFn, deps) => fn => {
  const gen = genFn();
  hook(() => {
    gen.next();

    return () => {
      gen.next();
    };
  }, deps);

  return fn;
};

The magical end result

As a result, we have render with only JSX and almost no nested functions in our component:

class Counter extends Hookable {
  initialCount = prop();

  theme = useContext(Theme);

  current = asDescriptor(useState(this.initialCount));
  clicked = asDescriptor(useState());

  onClick = useCallback(() => {
    this.current += 1;
    this.clicked = true;
  }, [this.current]);

  withLogging = fromGenerator(
    useEffect,
    function*() {
      console.log("did mount");
      yield;
      console.log("did unmount");
    },
    [],
  );

  render = this.withLogging(() => (
    <div>
      <p>
        Value:{" "}
        <span style=>{this.current}</span>
      </p>
      {this.clicked && <p>You already clicked!</p>}
      <p>Initial value: {this.initialCount}</p>
      <button onClick={this.onClick}>Increase</button>
    </div>
  ));
}

export default asFunctional(Counter);

And it even works:

For my personal eyes, the end result looks better and more readable, but the magic inside isn’t free:

  • it doesn’t work in Internet Explorer
  • the machinery around Proxy might be slow
  • it’s impossible to make it properly typed with TypeScript or Flow
  • metaprogramming could make things unnecessary more complicated

So I guess something in the middle (functor-like approach?) might be useful for real applications.

Gist with source code.

* hero image contains a photo of a classroom in Alaska



comments powered by Disqus