MVVM in React 🔥🔱🔥🔱
David Alsh
Posted on September 21, 2024
This is going to be an unpopular one but here goes...
I have been finding that writing my React applications using the MVVM pattern has been preeeetty pretty nice.
Let me explain.
The Problem
I know, I know.
Functional programming is the best programming.
React is functional by design so you should never do something different.
MVVM is the pattern used by Angular 🤮 and Vue 🤮.
Mutating variables is for plebs.
Yeah I know.
But have you ever;
had to migrate between an older version of React to a newer one and be a little stuck because your business logic depended on React features that had breaking changes? (e.g. class components -> functional components)
had performance issues tied to React's rendering cycle and struggle to address them because your business logic was tied to your presentation logic?
wished you could use use async/await in your components but are instead forced to thunk promises through a colossal state management digest cycle?
been stuck on testing React components, not exactly sure how to mock things?
gotten dizzy trying to think through functional components?
Yeah... Probably just me
Traffic Light
This is a super simple example but imagine a basic traffic light component.
You want the traffic light to start at green, stay green for 3000ms, transition to yellow for 500ms then red for 4000ms.
A likely conventional approach to this would be something like this:
const states = {
green: { duration: 3000, next: 'yellow' },
yellow: { duration: 500, next: 'red' },
red: { duration: 4000, next: 'green' },
}
function TrafficLight(props) {
const [color, setColor] = useState('green')
setTimeout(() => setColor(states[color].next), states[color].timeout)
return <div>{color}</div>
}
You might put the setTimeout
in a useEffect
to ensure you can cancel the timer but you get the point
LGTM, what's wrong here?
It's my view that the best projects are those that are accessible to engineers of all experience levels and that are architecturally resilient to lower quality contributions. After all, not everyone is a 10x developer with 20 years experience and that's okay, we can just factor that in.
For that to be the case; code needs to be plain, obvious and, unless required, avoid the use of clever framework-specific magic.
In the above example, the business logic is itself is tied to the render cycle of the framework. This has the effect of limiting the pool of contributors to "framework" engineers / React specialists.
In my travels I have seen many applications do this sort of thing, perhaps at a higher level of abstraction (embedded within state management solutions and so on), but when boiled down practically operating like this.
Alright, so what are you suggesting then?
Let's separate this problem into two halves.
- Represent the state for the view
- Render the state for the view
The View Model
Lets make a basic container for the view state.
const sleep = duration => new Promise(res => setTimeout(res, duration))
const states = {
green: { duration: 3000, next: 'yellow' },
yellow: { duration: 500, next: 'red' },
red: { duration: 4000, next: 'green' },
}
class TrafficLightView {
@reactive color = 'green'
#active = true
async onInit() {
while(this.#active) {
await sleep(states[this.color].duration)
this.color = states[this.color].next
}
}
onDestroy() {
this.#active = false
}
}
I know, I know, I am using a disgusting class
🤮.
Don't worry, as long as you never use extends
, it's just a convenient way to group state and associated functions together. Composition over inheritance ✨
So why is this good?
Firstly, it doesn't matter if you're a React developer, Angular developer, C++ developer, functional programmer, non-functional programmer - you understand what's going on here.
Secondly, you can use async
/await
, which is pretty nice IMO
Lastly, while testing this still requires mocking a global variable (we can fix that), you can assert success against object properties rather than against DOM changes.
Quick Note on Reactivity
I don't want to dive deeply into this but let's assume you're using something like Mobx where you can decorate class properties to trigger renders when properties change.
If people are interested, I'll write another article on how reactivity works.
import { reactive, subscribe, useViewModel } from './rx.ts'
class TrafficLightView {
// Converts "count" into a getter/setter
@reactive
count = 0
// Called by "useViewModel" hook
async onInit() {
this.count = 1
}
// Called by "useViewModel" hook
async onDestroy() {
console.log('bye')
}
}
const tlv = new TrafficLightView()
subscribe(tlv, () => console.log(tlv.count))
tlv.onInit()
The Presentation Layer
So how do we consume this in React?
Let's use the useViewModel
hook from our library
import { reactive, subscribe, useViewModel } from './rx.ts'
import { sleep } from './utils.ts'
const states = { /* ... */}
class TrafficLightView {
@reactive
color = 'green'
#active = true
async onInit() {
while(this.#active) {
await sleep(states[this.color].duration)
this.color = states[this.color].next
}
}
onDestroy() {
this.#active = false
}
}
function TrafficLight() {
const vm = useViewModel(() => new TrafficLightView())
return <div>{vm.color}</div>
}
A Real Use Case
I know the above implementation is more verbose when compared to the more conventional React approach - however the benefits start to shine through when applied to something less trivial.
Say we have a REST API we want to talk to that has paginated results and we want to ensure that the API layer is abstracted away such that it can be tested without the framework.
This is just plain, framework agnostic, TypeScript:
export type Gif = {
id: string
url: string
image: string
}
class GiphyService {
async *getTrending(): AsyncIterableIterator<Gif[]> {
let offset = 0
while (true) {
const response = await fetch(
`https://api.giphy.com/v1/gifs/trending?offset=${offset}&limit=20`
);
if (!response.ok) {
throw new Error("Request failed")
}
yield (await response.json()).data
offset += 20
}
}
}
This service provides you the trending gifs in the form of an async iterator where pagination is automatically handled when the iterator is advanced
const giphyService = new GiphyService()
const pages = giphyService.getTrending()
const page1 = await pages.next()
const page2 = await pages.next()
const page3 = await pages.next()
Let's write a View Model + Component
class HomePageViewModel {
#pages: AsyncIterableIterator<Gif[]>
@reactive gifs: Gif[]
constructor(giphyService: GiphyService) {
this.gifs = []
this.#pages = giphyService.getTrending()
this.loadMore()
}
async loadMore() {
// Mutation triggers a re-render
this.gifs.push(...await this.#pages.next())
}
}
function HomePage({ giphyService }) {
const vm = useViewModel(() => new HomePageViewModel(giphyService))
return <div>
<div>
{vm.gifs.map(gif => <img
key={gif.image}
src={gif.image} />)}
</div>
<button
onClick={() => vm.loadMore()}>
Load More
</button>
</div>
}
Notice how the template now only contains logic to handle presenting the data rather than managing presentation and state.
This also enables us to use async
/await
to drive React rendering, something that is otherwise a little annoying.
Now the GiphyService
can be tested by itself and the component can be tested by itself.
Other Advantages, Forms
class HomeViewModel {
@reactive
input = new FormField('')
}
function Home(props) {
const vm = useViewModel(() => new HomeViewModel())
return <div>
<input {...vm.input} />
</div>
}
Where FormField
looks like:
class FormField {
@reactive
value
constructor(defaultValue) {
this.value = value
}
onChange = (event) => {this.value = event.target.value}
}
Now we can expand this field type with validation, validation states and even create larger abstractions like FormGroup
which composes multiple FormField
objects.
class HomeViewModel {
@reactive
form = new FormGroup({
name: new FormField({
placeholder: 'Enter your name',
defaultValue: '',
}),
email: new FormField({
placeholder: 'Enter your email',
defaultValue: '',
validators: [emailValidator]
})
})
async submit() {
if (!this.form.isValid) {
alert('Form not valid!')
}
await submitToApi(this.form.toJSON())
}
}
function Home(props) {
const vm = useViewModel(() => new HomeViewModel())
return <div>
<input {...vm.form.name} />
<input {...vm.form.email} />
</div>
}
Perhaps even generating forms directly from the state
function Home(props) {
const vm = useViewModel(() => new HomeViewModel())
return <div>
{vm.form.fields.map(field => <input {...field} />)}
</div>
}
Again, the objective is to keep the component code minimal, focused as much as possible on presentation whist the view model can be groked with eyes that are not specialized to React.
Closing Thoughts
It's a bit unconventional, but I feel that the intersection of the MVVM/MVC worlds of Angular and Vue with the React world has the potential to improve the readability, testability and portability of React projects.
It doesn't require anything fancy to implement and, though unconventional, it feels like it naturally fits within React
Posted on September 21, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.