Is there a react way to store a mutable class instance objects in state?
Asked Answered
D

4

29

React state shouldn't be mutated directly. But, what if the state is an instance of a class that shall be mutable with its own methods. Is there another way than having to deep-clone and re-instantiate the object with new parameters?

In General: What is the react way for a class object created in parent components to be used in subcomponents while maintaining its properties in the parent state (which is passed down via props/context)?

Sample class

class Car{
    constructor(data){
        this.data = data
    }
    changeColor = (newcolor) => this.data.color = newcolor
}

Sample Parent Component

const App = ({data}) => {
 const [car, setCar] = useState(new Car(data))
  return (
    <div>
      <CarViewer car={car} />
    </div>
  );
};

Sample Sub componeent

const CarViewer = ({car}) => {
  return (
    <div>
      The color is: {car.data.color}
    <button onClick={()=>car.changeColor("blue")}>Change color to blue </button>
    </div>
  );
};
Defibrillator answered 5/2, 2021 at 11:54 Comment(3)
And what would be the practical examples of this?Cyanite
I think this would be practical for any use case where you have a set of methods and corresponding properties in a stateful class instance. Example: A class that fetches data with built-in caching functions and has access to window.location.state. It's passed via context/props to other components who use this instance. I think OOP can be a great approach (regardless of class or prototype annotation) to bring order in your apps and asking myself whether it's possible to use it in a "react way" when the instances shall be stateful.Defibrillator
I found this useful #51832324Osteoid
M
17

I've been struggling with this same question for a few years now, and this is what I've come up with. I'm refining the ideas constantly, so take it all with a grain of salt and don't be afraid to critique or clarify anything that feels wrong or unclear. Also, like with any general or pattern-based solution, it's not always worth the cost or appropriate for the situation.

Components are Simply: Data Down, Events Up

The foundational premise here is that the implementation code of any given component should work exclusively in terms of plain data and callbacks that come from other code. By "from other code", I mean either a prop, the output of a hook, or the result of a pure computation. By "plain data" I mean that any mutable instances should be "projected" into an immutable subset for the component to consume, or at least treated as opaque such that the mutable details are ignored.

In short: don't even let components "know" about mutable models! Have something else make it simple for them. A component should focus on rendering the data given to it and reporting events up via callbacks.

For your example, that means neither App nor CarViewer should mess with a Car directly.

Use Hooks for Model Handling

So based on that rule, how does a model like Car turn into something usable by a component? Props and pure functions don't solve the problem but only push it back; the data still has to come from somewhere, after all. That only leaves hooks.

But "use a hook" is too vague to be meaningful. We need to know a) how to get your hands on the model in the first place and b) how to turn it into something a component is allowed to use.

Maintaining Model Instances

Where does a model come from, and how do we keep it around? At the end of the day, it starts with business logic: build a new model with a constructor or factory function or pull a shared instance from some external provider like a DI container. In your example, this is new Car(data).

Once the instance is available, store it with useRef. Don't store it in useState because you're not rendering the value directly and will need to account for changes some other way anyway.

Make sure the hook creates (and cleans up!) models exactly when needed. The details vary based on use case, but here is a thread discussing tips for managing non-state instances in light of planned changes to react mounting behavior.

For your simple example, I'd just create a hook like this:

function useCar(initialData) {
    const carRef = useRef();
    if (!carRef.current) {
        carRef.current = new Car(initialData);
    }
    return carRef.current;
}

Maintaining Model Projections

Okay, so you finally have a model instance with the access details encapsulated in a hook. How can a component use it without breaking the primary rule? A hook must project the relevant model values and functions into an immutable, computed sub-value and store it in state. The trick is that this state needs to be updated with a re-computed projection whenever the projected values change. There are two generalized options for that.

The complete option is to have your model publish/emit change events that you can listen to and recompute the projection each time that happens. I like to use rxjs for this. Unfortunately, this requires the model to be designed with change events in mind, but that may be necessary anyway if changes can come from outside your view hierarchy (such as from a push notification).

If you can't rely on the model to inform you of changes, you'll have to intercept your own changes. To do this, you can wrap the projected change functions so that they directly update state afterward. Let me show you what that could look like:

function useCarProjection(car) {
    const [projection, setProjection] = useState(projectCar(car));
    return {
        ...projection,
        // each change function modifies the car AND updates the projection
        changeColor: (newColor) => {
            car.changeColor(newColor);
            setProjection(projectCar(car));
        },
    };
}

function projectCar(car) {
    // include any data your components need
    return { color: car.data.color };
}

Notably, I had this hook take the car as a parameter so that it wouldn't be concerned with accessing or storing it directly. You'd need a third hook to combine the two, or you could just combine both into a single hook for simpler cases.

Myrmidon answered 14/4, 2022 at 16:13 Comment(2)
This may be a better fit with my task. My task is to handle authentication and sign-in. My project requires that I provide a "Principal" (an actual person) that may have one or more instances of "Persona" attached to it. Any persona may be "signedIn" or "signedOut". This model is straightforward to model as instances and classes. I've spent weeks trying to sort out useState, useEffect, and useCallback behavior in React. I'm going to try this approach and see if it helps.Bubal
@TomStambaugh, that sounds like a good reason to "project" from the complicated model to a simpler view-based set of state. You could publish a sort of "event" whenever the persona situation changes (signIn/Out) and have components listen for that event. That could be through rxjs or even just by having the model keep a list of functions to call when there's a change.Myrmidon
S
11

I think what you need to do is change your mental model of storing a class inside a react state and try different model like this which is more react way:

const CarViewer = ({ carData, changeColor }) => {
  return (
    <div>
      The color is: {carData.color}
      <button onClick={() => changeColor("blue")}>Change color to blue</button>
    </div>
  );
};

const App = ({ data }) => {
  const [carData, setCarData] = useState(data);

  const changeColor = (newcolor) =>
    setCarData((data) => ({ ...data, color: newcolor }));

  return (
    <div>
      <CarViewer carData={carData} changeColor={changeColor} />
    </div>
  );
};

EDIT: based on your comment, I think what you need is a custom hook like this:


const App = ({ data }) => {
  const { carData, changeColor } = useCar(data);
  
  return (
    <div>
      <CarViewer carData={carData} changeColor={changeColor} />
    </div>
  );
};

function useCar(defaultData = {}) {
  const [carData, setCarData] = useState(defaultData);

  const changeColor = (newcolor) =>
    setCarData((data) => ({ ...data, color: newcolor }));

  return {
    carData,
    changeColor,
    //... your other methods
  };
}
Sanguineous answered 5/2, 2021 at 12:17 Comment(9)
Well, imagine the class Car to be a more complex class with tens of methods and own properties and the class shall be reused in other places..Defibrillator
@Defibrillator that's exactly where custom hooks are coming into the sceneSanguineous
@Defibrillator check the answer again, I've edited itSanguineous
Thanks, I like this approach. But I still ask myself, is there a "react way" to work with stateful class instances / with class syntax, without using the setState handlers in each of the class method that change its properties?Defibrillator
I think you're answer is, don't store it using a useState and just use the car directly in your Component like a normal variable: const car = new Car(data)Sanguineous
But using it directly in my component its state data is restricted to that component. I want its state to be shared by all components using it. That's why I put it in the state in the first place.Defibrillator
Let us continue this discussion in chat.Defibrillator
"is there a "react way" to work with stateful class instances / with class syntax" the simple answer is "no" because React is not aware of internal changes to the Car object and won't re-render the CarViewer when it should. When you call useState(new Car(data)), your state is just store a reference to that Car object. As long as the reference stays the same, the component won't update. So you would need for the methods of a Car to return a new instance and then setState with the new instance.Andraandrade
I found this useful #51832324Osteoid
B
0

I'm struggling with the same question too. I tried to write a custom hook use-cls-state to handle this problem, and it might be helpful to you.

Let's take the Car class as example. After installation, the usage of use-cls-state hook would be like:

import { useClsState } from "use-cls-state"

class Car{
    constructor(data){
        this.data = data
    }
    changeColor = (newcolor) => this.data.color = newcolor
}

const App = ({data}) => {
  const [car, setCar] = useClsState(new Car(data))
  return (
    <div>
      The color is: {car.data.color}
      <button 
        onClick={() => {
          // Update class instance will not trigger re-render
          car.changeColor("blue")
    
          // Re-render is only triggered when calling state setter
          setCar(car)
        }>
          Change color to blue
        </button>
    </div>
  )
}

Be aware that updating class instance will NOT trigger re-render. Re-render is only triggered when calling state setter.

Baccalaureate answered 6/3 at 13:17 Comment(0)
T
0

I came up with this solution for my project. I use TypeScript, but you'll get the idea anyway (just get rid of types).

  1. Introduce iProjectable:

    export interface iProjectable<T extends object> {
        toProjection(): T;
    }
    
  2. Create a projectable class:

    export class Car {
        id: string;
        color: string;
    
        constructor(id: string, color: string) {
            this.id = id;
            this.color = color;
        }
    
        toProjection() {
            return {
                id: this.id,
                color: this.color,
                setColor: (color: string) => this.setColor(color),
            };
        }
    
        setColor(color: string): void {
            this.color = color;
        }
    }
    
  3. Create a custom reusable state hook:

    export function useProjectionState<T extends object>(ref: iProjectable<T>): T {
      const projection = ref.toProjection();
      const [state, setState] = useState<T>(projection);
      for (const [key, value] of Object.entries(projection)) {
        if (typeof value === "function") {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          state[key as keyof T] = <any>((...args: any[]) => {
            value(...args);
            setState(ref.toProjection());
          });
        }
      }
      return state;
    }
    
  4. Then in your component, do:

    const car = new Car("some_id_123", "red");
    export default function Component() {
        const data = useProjectionState(car);
    
        return (
            <Button title="Make it green" onPress={() => data.updateColor("green")} />
            /.../
        );
    }
    
Trillion answered 9/4 at 21:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.