3

I have a simple class, something like this:

class ReallyHugeClass {
  constructor() {
     this.counter = 0;
  }
  increment = () => {
     this.counter += 1
  }
}

If I use it in the code in a straightforward way it won't keep its state. The class will be recreated every time on render and it's not reactive at all.

const Component = () => {
   const instance = new ReallyHugeClass();
   return (
       <button onClick={instance.increment}> 
          {instance.counter}
       </button>
   )
}

Don't rush to say: you don't need the class! Write this:

const Component = () => {
   const [counter, setCounter] = useState(0);
   return (
       <button onClick={() => { setCounter(value => value + 1) }}> 
          {counter}
       </button>
   )
}

I used the ridiculously small class example, but the real one is complicated. Very complicated. I can't just split it into the set of useState calls.

Let's go forward. I can wrap the instance to useRef to save its value.

const Component = () => {
   const instance = useRef(new ReallyHugeClass());
   return (
       <button onClick={instance.current.increment}> 
          {instance.current.counter}
       </button>
   )
}

The value is saved, but it's still not reactive. I can somehow force the component to rerender by passing the corresponding callback to class, but it looks awkwardly.

What's the right pattern to solve such task in React? It looks that it's quite likely situation.

nitrovatter
  • 931
  • 4
  • 13
  • 30

2 Answers2

1

Create an abstract class that tracks "clients" that subscribe/listen to it.

class EventEmitter {
  constructor() {
    this.listeners = [];
  }

  addListener(listener) {
    this.listeners.push(listener);
  }

  removeListener(listenerToRemove) {
    this.listeners = this.listeners.filter(listener => listenerToRemove !== listener);
  }

  notify() {
    this.listeners.forEach(listener => listener());
  }

  getListeners() {
    return this.listeners;
  }
}

Extend your class with EventEmitter to allow React to listen to it for changes

class ReallyHugeClass extends EventEmitter { // <-- Extend the class
  constructor() {
     super();
     this.counter = 0;
  }

  increment = () => {
     this.counter += 1
     this.notify() // <--- Fire notify() after you update state  
  }
}

Create a React hook that can take in a class instance that extends EventEmitter and add listeners on mount (and remove on unmount)

import { useEffect, useState, useRef } from 'react';

const useEventEmitter = (instance) => {
  const [, forceUpdate] = useState(0); // <-- Simply incrementing a counter will cause a re-render
  const listenerRef = useRef(null); // <-- Hold a reference to the current listener function across renders

  useEffect(() => {
    const newListener = () => {
      forceUpdate(prev => prev + 1);
    };
    
    // Remove any existing listeners
    if (listenerRef.current) {
      instance.removeListener(listenerRef.current);
    }

    // Add the listener
    listenerRef.current = newListener;
    instance.addListener(newListener);

    // Cleanup listener and remove on unmount
    return () => {
      instance.removeListener(newListener);
    };
  }, [instance]);

  return instance;
};


You can define the class outside the render (same instance will be used across renders or you can useRef inside the component) and use the hook to listen for changes in your class and your React component will re-render everytime notify() is called from the class.

const instance = new ReallyHugeClass()  

const Component = () => {
     useEventEmitter(instance); // <-- "connect" to the class and make this component listen for changes to re-render
 
    return (
        <button onClick={instance.increment}> 
            {instance.counter}
        </button>
     )
}
lukeliasi
  • 239
  • 2
  • 5
0

One solution would be to use useRef and force rendering with a useState. Here an example:

const { useRef, useState } = React;

      class ReallyHugeClass {
        constructor() {
          this.counter = 0;
        }
        increment() {
          this.counter += 1;
          console.log(this.counter);
        }
      }

      function App() {
        const instance = useRef(new ReallyHugeClass());
        const [forceRender, setForceRender] = useState(true);

        return (
          <button
            onClick={() => {
              instance.current.increment();
              setForceRender(!forceRender);
            }}
          >
            {instance.current.counter}
          </button>
        );
      }

      const root = ReactDOM.createRoot(document.getElementById("root"));
      root.render(
        <>
          <App />
          <App />
        </>
      );
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <script
      crossorigin
      src="https://unpkg.com/react@18/umd/react.production.min.js"
    ></script>
    <script
      crossorigin
      src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
    ></script>
    <div id="root"></div>
Usiel
  • 671
  • 3
  • 14