DEV Community

Volodymyr Yepishev
Volodymyr Yepishev

Posted on

Using Classes Inside React's Functional Components

This article is about smashing square shapes into round holes using the force :)

React encourages you to use functional approach, but what if you are stubborn and want to use classes instead? Well, if you are stubborn enough, you can.

Let's assume we are writing a counter, and come up with a class:

export class Counter {
  private _value: number;

  constructor(initialValue: number) {
    this._value = initialValue;
  }

  public get value(): number {
    return this._value;
  }

  public increment(): void {
    this.add(1);
  }

  public decrement(): void {
    this.add(-1);
  }

  public add(n: number): void {
    this._value += n;
    console.log(`value changed, new value is: ${this._value}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Then we go and choose a UI library and decide to use React, we're being naive and try to use our Counter class inside a functional component, creating a couple of instances:

import { Counter } from "./counter/Counter.class";

export function App(): JSX.Element {
  const c = new Counter(100);
  const c2 = new Counter(-200);
  return (
    <div className="App">
      <section>
        <button onClick={() => c.decrement()}>decrement</button>
        {c.value}
        <button onClick={() => c.increment()}>increment</button>
      </section>
      <section>
        <button onClick={() => c2.decrement()}>decrement</button>
        {c2.value}
        <button onClick={() => c2.increment()}>increment</button>
      </section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We hit some buttons and find out React does not update the UI, though in the console it's clear the values are getting updated. Now we could turn a class into a custom hook, but that'd be no fun.

Let us instead think about why the updates do not occur. The answer is simple: props did not change, component state did not change, no need to update the component. Pretty reasonable. So what we could do? Basically we need class methods to start forcing React component re-renders, which means they need to use some hooks.

As Typescript provides decorators for methods, we could use a custom decorator that would trigger component re-render when instance method is run:

import { useState } from "react";

export function useReactChangeDetection(
  target: unknown,
  propertyKey: string,
  descriptor: PropertyDescriptor
): void {
  const [, setState] = useState<string | undefined>();
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: unknown[]) {
    const result = originalMethod.apply(this, args);
    setState((prev) => (prev === undefined ? "" : undefined));
    return result;
  };
}
Enter fullscreen mode Exit fullscreen mode

What is interesting, React does not allow using hooks outside functional components or other hooks, so we cannot apply the decorator directly to the Counter class, we need to think of something else.

Since our goal is to apply the hook-decorator to the Counter class, what we could do is writing a custom hook that manufactures a class extending Counter and applying the decorator to a given method name. Of course that requires us to write a generic that can extract the method names:

export type ClassMethod<T> = {
    [P in keyof T]: T[P] extends (...args: any[]) => any ? P : never;
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

Now we can create our hook go generate extended classes of Counter superclass:

import { useMemo } from "react";

import { ClassMethod } from "../ClassMethod.model";
import { Counter } from "./Counter.class";
import { useReactChangeDetection } from "./useChangeDetection.hook";

export const useCounterClass = (
  method: ClassMethod<Counter>,
  value: number
) => {
  class UseCounterClass extends Counter {
    @useReactChangeDetection
    public override [method](n: number): void {
      super[method](n);
    }
  }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  return useMemo(() => new UseCounterClass(value), []);
};
Enter fullscreen mode Exit fullscreen mode

Note how we override the super method and decorate it with the useReactChangeDetection hook, which is now perfectly fine as it is used inside a hook. Swapping the new class Counter with our new hook, we can even choose which class methods will trigger component update when instantiating:

import { useCounterClass } from "./counter";

export function App(): JSX.Element {
  const c = useCounterClass("add", 100);
  const c2 = useCounterClass("decrement", -200);
  return (
    <div className="App">
      <section>
        <button onClick={() => c.decrement()}>decrement</button>
        {c.value}
        <button onClick={() => c.increment()}>increment</button>
      </section>
      <section>
        <button onClick={() => c2.decrement()}>decrement</button>
        {c2.value}
        <button onClick={() => c2.increment()}>increment</button>
      </section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

There, all the state is inside class instances and React has to respect the updates, outrageous, isn't it? :D

Oldest comments (0)