loading...

Clean MVC architecture with Model-React

tarvk profile image Tar ・5 min read

Hello everyone!

I am quite new to this website, but would like to share a solution I recently came up with to a problem I think more react developers are having.

First I will explain the problem/annoyance I've had while developing websites with react, and then I will go over my solution.

The problem

React is a great framework, but I've noticed that quite some people struggle with data management within this framework, especially global data. I was one of those people when I just started using it.

A lot of people start off learning an Object Oriented Programming (OOP) approach when being introduced to programming. Most educational institutes seem to believe this is the way to go (whether that's true is of course up for debate), and react components themselves also loosely feel like the OOP paradigm.

But when it comes to global data management, the most common solution is usage of reducers. This pattern can feel quite far from home to an OOP programmer, and seems closer to functional programming (at least to me, but I have little to no experience with functional programming).

Below is a simple example of a reducer for people that aren't familiar with them.

import { render } from "react-dom";
import React, { useReducer } from "react";

const initialState = { count: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case "reset":
      return { count: action.payload };
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <React.Fragment>
      Count: {state.count}
      <button
        onClick={() =>
          dispatch({
            type: "reset",
            payload: initialState.count
          })
        }
      >
        Reset
      </button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </React.Fragment>
  );
}

render(<Counter />, document.getElementById("root"));

Simply making our application and data in an OOP style won't work on its own, since react component life cycles are quite specific meaning they can't be part of the OOP data model. We can completely separate the OOP data model from the react components, but then we somehow still need to inform the components about state changes.

My solution

Completely separating the data model from the react components comes really close to what I believe to be a neat solution. It has proper separation of concerns, since the data and behavior is separated from the looks and interaction of the application. But one issue remains, we have to inform react components about data changes.
The obvious solution for this is to use the observer pattern;
Alt Text
This allows us to register listeners from the react components, to listen for data changes and rerender the component when data was altered.

Setting up these observers for all data entries would be a pain however, and making one observer for the whole model would be bad for performance (since everything would rerender if just 1 thing changes).
So my pattern is an adaptation of the observer pattern, that is more concise and easier to use in react applications.

Any data that can be retrieved from a model takes an extra argument: the Data Hook. This Data Hook is essentially a listener. This way we can immediately subscribe to changes of a field with only a single call. And only a single method on the model has to be present per field, rather than also requiring a register and unregister observer method.

Model-React is the library I wrote to support this pattern. It contains so called "Data Sources" that can register these data hooks and notify them whenever data is changed, together with some implementations of Data Hooks. The main Data Hook present is the useDataHook react hook that allows react components to hook into the model data.

The library has full typescript support, but the example below is in javascript to make it easier to follow. It shows usage of the useDataHook hook and Field Data Source.

import {render} from "react-dom";
import React from "react";
import {Field, useDataHook} from "model-react";

class Person {
    constructor(name, age) {
        this.name = new Field(name);
        this.age = new Field(age);
    }
    setName(name) {
        this.name.set(name);
    }
    getName(h) {
        return this.name.get(h);
    }
    setAge(age) {
        this.age.set(age);
    }
    getAge(h) {
        return this.age.get(h);
    }
}

const PersonEditor = ({person}) => {
    const [h] = useDataHook();
    return (
        <div>
            <input
                value={person.getName(h)}
                onChange={e => person.setName(e.target.value)}
            />
            <input
                type="number"
                value={person.getAge(h)}
                onChange={e => person.setAge(Number(e.target.value))}
            />
        </div>
    );
};

const PersonProfile = ({person}) => {
    const [h] = useDataHook();
    return (
        <div>
            Name: {person.getName(h)} <br />
            Age: {person.getAge(h)}
        </div>
    );
};

const john = new Person("John", 1);
render(
    <div>
        <PersonEditor person={john} />
        <PersonProfile person={john} />
    </div>,
    document.getElementById("root")
);

In addition the library provides some tooling for dealing with asynchronous data. The Data Hook may contain a callback to register whether data is currently still loading, which Data Sources can utilize. This way we can show alternate components when data is still loading, as can be seen in the example below.

import {render} from "react-dom";
import React from "react";
import {DataLoader, LoaderSwitch, useDataHook} from "model-react";

// A random function to generate a short random number
const random = () => Math.floor(Math.random() * 1e3) / 1e3;

// A delay function to fake some delay that would occur
const delay = () => new Promise(res => setTimeout(res, 2000));

// Pass a loadable data source to an element, and use a loader switch to handle the state
const SomeData = ({source}) => {
    const [h, c] = useDataHook();
    return (
        <div>
            <LoaderSwitch
                {...c} // Passes the state
                onLoad={<div>Loading</div>}
                onError={<div>Data failed to fetch</div>}>

                {source.get(h)}

            </LoaderSwitch>
            <button onClick={() => source.markDirty()}>reload</button>
        </div>
    );
};

// Create a loadable data source anywhere, it may be part of an object, or be on its own
export const source = new DataLoader(async () => {
    // Simply returns random data after some delay, would more realistically be an async data fetch
    await delay();
    return random();
}, 0); // 0 is the initial value

render(<SomeData source={source} />, document.body);

That's basically it. The result of this code can be seen at QuickStart and LoaderSwitch.
I've used this library myself for a couple of projects now, including a group project which gave me some insights that helped me improve it, and I've really enjoyed using it.
I hope this can also be helpful to other people!
The library, including a fair bit of documentation and numerous examples, can be found here.

Discussion

pic
Editor guide