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.