Development
Reactive rich domain models in React+TypeScript
Over the last few years, we’ve seen the world of web frameworks explode. Many new frameworks have emerged to seemingly all solve the same problem. That is: how do we solve the complexity problem in our frontend applications? Each framework deals with this issue in various ways and has their own pros-and-cons. As companies lock themselves into specific framework stacks, they start hiring “expert” software developers in those frameworks. A quick search on LinkedIn reveals that Redux is a core necessity for some companies alongside React.
My team and I have explored various ways of dealing with the growing complexity in our stack. One of the reasons for this complexity is the extensive business logic in our frontends. Our frontend applications require immediate feedback to users. Our users are potentially in remote areas with slow internet connectivity and waiting on backends for business logic is unfeasible. The application configures data in real-time and calls the backends to create visualizations of that data. The configuration of the data is rarely committed to storage and therefore the round trip to the backend is unnecessary. With our frontend containing a lot of business logic, we started to wonder: why is it so hard to add new functionality to our growing business needs?
In literature, solving business logic complexity is done in a myriad of ways. Domain Driven Design (DDD) seems to be a favorite of many. Especially if you have a strong Object Oriented mindset. As I previously mentioned, a large amount of business logic is found in our frontend, specifically a React based frontend. Up to today, we wanted to code everything within the React ecosystem, with very little custom tooling, therefore we used Redux because of its integration with React. This approach is fine, however, you end up with an anemic domain model, usually only a type interface. Although anemic models work well, you then end up with many Redux “services” or “helper files” to deal with the business logic of those anemic models. This has the consequence of spreading out the business logic across many files.
There was only one question I asked myself over the past 6 months while developing a new subset of our application. With a monolithic frontend, locked in a tech stack 2 major versions behind, and a complex spaghetti of helper files; I wondered, why are we here in the first place? How do we reduce the versioning hell? How do we integrate the business logic that is spread out into multiple helper files? Some of our helper files were 3 thousand lines long, it contained the business logic for our 15+ entities. How did we get here? After a relatively small step back, our team identified that Redux was one of the root causes. Redux suggests Anemic domain models, and therefore suggests separating the business logic into helper files. We wanted to know if there was a way to integrate rich domain into React+Redux in a way that we don’t have to rewrite our entire frontend, or to create our own frameworks. We’ve scouted the internet to find an answer to this question, to no avail.
Let me take a step back to clarify anemic vs. rich domain models if you’re not familiar with DDD principles. Everybody has different definitions of Domain driven design. For the purpose of this article, DDD is a way to map code to real world concepts. Important principles in DDD include the creation of Domain models, that is, a class (usually) that uniquely defines a real-world concept. Below, I go into two important variations to these domain models.
Anemic domain models
Anemic domain models are objects without any business logic associated with their definitions. They are usually represented in TypeScript by a single interface, known as a Data Transfer Object (DTO). The interface describes the shape of the JavaScript object, using TypeScript type-checking we can ensure proper usage. This interface is not connected to any other model and has little-to-no methods for business logic. It certainly does not have its own state transitions. Here is a typical example in TypeScript:
export interface MyPetShopDTO {
readonly id: number;
readonly storeName: string;
readonly pets: MyPetDTO[];
}
This is perfect, it would work with Redux and React as is. However, it does not have any business logic. What happens if you want to update this model’s name in a typical React+Redux application?
To propagate your changes to your Redux store, you’d have to:
- Create a Redux selector to get this model in your components
- Add an event listener to an input in a component
- Dispatch an event to Redux to update this name in their state
- Catch that event in Redux using a Slice action
- Find the entity in our Redux state, usually by using an entity ID
- Recreate an object for the entities (using spread operators) in our state, otherwise Redux doesn’t let React re-render
- React re-renders the entire tree, passing through the React reconciliation to know which props have changed and which component to update.
- If a prop changes, re-render all the children all the way down to your component. No matter if this name is used in a tiny part of your application.
Sounds like a pain to write. In this case, all of that work, for a basic example of a name change. Let’s compare this to how you would typically assign a variable change in regular JavaScript without a framework.
myPetShop.storeName = 'Montreal Pets'
A typical React+Redux application requires 8+ steps to accomplish something that JavaScript does in a single step. Not only that, but requires developers to be aware of all the steps mentioned above. Due to the high complexity of the steps above, the software industry often creates copy-pastable boilerplate of the entire Redux hierarchy. Developers can then copy-paste the snippets they need to make the code run. They don't stop to analyze the complete code flow, through these third party libraries. Without understanding the complete flow, how can we expect developers to write unit tests? Especially when doing trivial things like changing a pet's name. In React+Redux this would require nearly 15 lines of code in a unittest's beforeEach clause? And that is, without any business logic tested yet. It feels like we are drowning in framework lingo that has been building up overtime without taking a step back and thinking about what we are even doing in the first place.
Anemic domain models - Business logic
Let’s add some business logic to our anemic model. The pet store employees may add or remove pets that are available for adoption. These pets have various configuration options filled via a modal. On modal close, the pet is added to the pet store.
export const slice = createSlice({
name: 'petStoreSlice',
initialState,
reducers: {
addPet(state, action: PayloadAction<MyPetDTO>) {
const hasPet = state.petStore.pets.find(
(pet) => pet.id === action.payload.data.id
);
if(!hasPet) {
const defaults = getPetDefaults(action.payload.data);
const newPet = {...defaults, ...action.payload.data);
state.petStore = {
...state.petStore,
pets: [...state.petStore.pets, newPet]
}
}
},
Rich domain models
What if we lived in a world where our data and our business logic are in the same place. Agnostic of any frameworks. The ability to import these across frontend application stacks. JavaScript is JavaScript, it can be used regardless of the rendering framework used. Unit testing our application becomes significantly easier, you no longer need to mock and handle all of these frameworks. Separation of concerns is easier to enforce if you plan your models correctly according to separation of concerns and context boundaries.
A good understanding of your entities and the interactions between them is required, as well as the context in which they live… But that is a discussion for another day. Typically a tech lead/developer of a project will have done the analysis upfront and determined the models, their interactions and their business logic. Even if it isn’t upfront, an object oriented approach will significantly help the future flexibility of your applications by having encapsulation and inheritance.
What would a rich domain model look like in TypeScript?
export interface PetStoreDTO {
id: number;
storeName: string;
pets: PetDTO[];
}
export interface IPetStore extends PetStoreDTO {
pets: IPet[];
addPet: (newPet: IPet) => void;
removePet: (pet: IPet) => void;
createOffspring: (parent1: IPet, parent2: IPet) => IPet;
}
Notice how we keep the PetStoreDTO from the anemic domain model, however we create a new interface that extends it. In this new interface, we indicate that concrete implementations will contain 3 functions. Add a pet, remove a pet and create offspring. Note that the `pets` array was overridden in this interface with a new type: the IPet type. The DTO of the pet store only contains the raw JSON of the pets, but not the class-based models. However the interface of the pet store (and/or the class based model of the pet store) does have pets as models. To remedy this change between DTOs and models, the interface properties are overridden.
The concrete implementation of this class could look like:
class PetStore implements IPetStore {
id: number;
storeName: string;
pets: IPet[] = [];
addPet(newPet: IPet) {
this.pets.push(newPet);
}
}
Notice how simple this is, you can compile this down from TypeScript to JS and import it into any other JS projects.
All fun and games, but what would that look like with React?
Well, that’s where things get interesting. React doesn’t like class instances. React uses an algorithm called “Reconciliation” to know which props have changed and which components need to re-render. It knows which props have changed by looking at object references (similar to C pointers) in their props, aka shallow comparaison. When using class based entities, changing values within those classes do not cause the reference of the class entity to change therefore React would not consider this a prop change and would thus not re-render.
Let’s take this a look at this example:
// Instantiated somewhere else
const petStore = new PetStore({
id: uuidv4(),
storeName: 'Montreal Pets',
pets: []
});
// React Component:
const PetStoreHome: React.FC = () => {
const onAddPet = () => {
petStore.addPet(new Pet());
}
return (
<div>
<p>Pet store: ${petStore.storeName}</p>
<PetList petStore={petStore} />
<button onClick={onAddPet}>Add Pet</button>
</div>
);
};
In this case, we are instantiating a new class to hold the petStore, potentially in another file (ex: a service file, a repository, perhaps Redux). If we do any changes to petStore, React will not rerender this component. Furthermore, PetList will not re-render even if we call addPet on petStore. Class based instances in React are truly problematic because of the Reconciliation algorithm only checking for reference equality.
The PetStore model is a rich domain model because it contains business logic (ex. addPet) however it isn’t “reactive” in React. Changing any of the values within the model does not cause any re-renders. We found a way to use rich domain models and have React… react to changes.
Reactive Rich Domain Models in React
To accomplish this, we needed a way to listen to any and all changes on a model class. Changing a single property or a myriad of properties on a class should cause all or some of the react components to re-render to reflect those changes. Not all components in the DOM/React tree will need to be re-rendered though. A change in the store name shouldn’t cause re-renders in the PetList. To listen to changes made on a model class, we used the Proxy API and a base class that would be extended by our models.
class PetStore extends BaseModel implements IPetStore {
...
constructor(petStore: PetStoreDTO) {
super();
...
}
The only thing that changes is the “extends” directive and the super call within the constructor. Everything else is exactly the same.
We also created a React Hook that would link up with the base model and register a callback on property changes:
// React Component:
const PetStoreHome: React.FC = () => {
useModelUpdate(petStore, 'storeName');
...
}
The hook only updates this component if the ‘storeName’ property on the petStore model is changed. Since the PetList
component only requires the petStore as a prop, which never changes reference between renders, the PetList component isn’t re-rendered, gaining a huge performance boost.
If we want to add a pet, then the `PetStoreHome` component doesn’t need to be re-rendered, only the PetList component should be re-rendered. This can be accomplished by adding a new hook registration in the PetList component:
const PetList: React.FC<PetListProps> = ({petStore}) => {
useModelUpdate(petStore, 'pets'); // array changes (ex: push, pop, splice, etc)
return (
<ul>
{petStore.pets.map((pet) => <PetDetails pet={pet} />)}
</ul>
);
};
If a pet changes name, then only the PetDetails would need to be re-rendered by using a new hook registration. Any other changes to “pet” (ex. id changes) would not cause a re-render:
const PetDetails: React.FC<{ pet: IPet}> = ({ pet }) => {
useModelUpdate(pet, 'name');
return (
<li>{pet.name}</li>
);
}
As you could imagine this is an extremely powerful way to render your React components by explicitly controlling the React update flow. Only requiring components to re-render if properties they use changes. Perhaps sometimes you don’t even need any re-rendering if properties of a class changes. Some properties of some models do not have a visual representation and are only used internally for calculations of derived properties, or they should only be applied during an event.
A world without Redux in React
Although there are various ways to handle state in React, a popular package is React-Redux. It’s a global state management package. Its primary goal is to streamline state management. It’s a fairly large package that forces us to develop in certain ways. In a typical React+Redux application you can find hundreds if not thousands of “useDispatch” and “useSelector” hooks littered throughout the code. Each anemic model has dozens of actions, state reducers, thunks and selector functions.
Testing the Slices becomes extremely difficult as we have to remember the useSelector sequence to properly mock the implementation or we have to create an entire store for our unit tests. My team took quite a bit of time to get the slice testing under control and we ended up ditching unit testing slices in favor of Cypress end-to-MockAPI testing.
With rich domain models, we can get rid of actions, state reducers and selector functions. After removing most of redux’s core principles, we were left with just thunks. That left me with two questions: How do I get rid of these thunks, and where do we keep our rich domain models?
I found an answer to this question in some domain driven design principles: the repository. Similarly to a slice, the repository holds the state of a model and defines the thunks. A single class to handle all the backend communications and store models. The OG BFF.
class PetStoreRepository extends BaseRepository {
myPetStore: IPetStore | null = null;
isFetching = false;
lastError: unknown = null;
async fetchPetStore() {
if (this.isFetching) {
return;
}
this.isFetching = true;
try {
const response = await backend.get<PetStoreDTO | null>(
'localhost:8888/api/pet-store',
);
if (response.data) {
this.myPetStore = new PetStore(response.data);
}
this.lastError = null;
} catch (e: unknown) {
this.isFetching = false;
this.lastError = e;
} finally {
this.isFetching = false;
}
}
}
const petStoreRepository = new PetStoreRepository();
export default petStoreRepository;
So how do we use this in a React application?
// React Component:
import petStoreRepository from ‘repositories/petStoreRepository’;
const PetStoreHome: React.FC = () => {
useRepositoryUpdates(petStoreRepository); // Re-renders if any property changes
const petStore = petStoreRepository.myPetStore;
const isLoading = petStoreRepository.isFetching;
useEffect(() => {
if(!petStore && !isLoading) {
petStoreRepository.fetchPetStore();
}
}, [petStore, isLoading]);
if(isLoading) {
return <p>Loading...</p>;
}
if(!petStore || petStoreRepository.lastError) {
return <p>An error occurred</p>;
}
...
}
This is very similar to how you would do it with Redux with a dispatch call. Except we don’t have a dispatch call. This reduces our selectors since we use the repository directly, mocking becomes essentially effortless for testing purposes. The code is much more readable. Updating any of the pet properties in event handlers is straightforward. You no longer need to go on a dispatch action chase within the code to find the ultimate state change. Debugging is straightforward with class based entities, the React debugger displays the class variables in the debug tree. The only loss, which our team didn’t use, was the time travel ability of the Redux package.
Conclusion
Further tests of the Rich Domain Models + React would require a mix of breadth and depth in an application using this approach. The examples used in this article were extremely limited in complexity. However, our application seemed to truly benefit from these rich domain models both in performance, in simplifying the complexity of code, and in readability. This approach may also be more stable as time goes on: updates in packages would be relatively simple to implement, the real reactiveness comes from the BaseModel class and the useModelUpdate hooks. The approach detailed in this article works with both class based React, and hook based React, hopefully the next major version of React is as easy to integrate into this Reactive Model strategy. Changing the implementations for future updates in React would only require 2 file changes.
Object Oriented Programming isn’t too popular in the frontend frameworks and packages discourage us from using this pattern. As our applications become more and more complex, and business logic starts belonging in the frontend, our tools and frameworks need to adapt to this. As it is, React and Redux doesn’t make OOP a viable pattern, and yet it is an extremely powerful way to write applications. I may suggest trying this solution out if you have a lot of business logic and you know the boundaries between your entities. If you need validation and immediate interactivity in the frontend, this may also be a viable option for your applications. If you want to have an Object Oriented Programming pattern in your React applications without losing the benefits of React and without too much hassle, this approach may be for you.
One of my colleagues, wrote a blog on Handling global state in React in 2022, in which he details the various libraries available on the NPM registry for global state management.
Note: This approach was not benchmarked. I have no idea what the performance looks like depending on the breadth and depth of your application versus other popular packages. I also cannot guarantee that the GitHub gists will work for all entities and all cases (very very deeply nested object updates across arrays and objects?). Any feedback, suggestions and comments is welcome in the GitHub gist and if you have any questions do not hesitate to reach out to us.
Photo credit: Christopher Gower
Did this article start to give you some ideas? We’d love to work with you! Get in touch and let’s discover what we can do together.