MVVM in React 🔥🔱🔥🔱

alshdavid

David Alsh

Posted on September 21, 2024

MVVM in React 🔥🔱🔥🔱

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>
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

Where FormField looks like:

class FormField {
  @reactive
  value

  constructor(defaultValue) {
    this.value = value
  }

  onChange = (event) => {this.value = event.target.value}
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
alshdavid
David Alsh

Posted on September 21, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related