Declarative MVC via Jotai & React

React is a library for declaratively specifying user interfaces. Jotai is a complementary library for declaratively modeling state in a monadic way. When these are used purposefully, what arises is a very sane MVC architecture built with boring technology [1].

MVC is a decades-old pattern to separate concerns in an application in three primary buckets - to lay out succinctly:

  • View: What it looks like
  • Controller: What it does
  • Model: What it is

I’ve laid out the above in order of ‘View –> Controller –> Model’ as I think that ordering more clearly lays out the hierarchy in terms of cause-and-effect arising from the user.

What MVC isn’t

MVC isn’t a network boundary abstraction; instead it’s a means of more generally representing or architecting software. This article focuses on frontend architecture, but MVC also works on the backend; that aspect isn’t mutually exclusive.

MVC isn’t even really about classes or objects in my view - in contrast I would characterize this particular instantiation of MVC as a particularly functional and declarative one.

Diagrammatic overview

  • View: React components
  • Controller: Jotai ‘action’ atoms
  • Model: Jotai state

mvc-diagram

The above diagram illustrates the ‘knows about’ (flow of control, or ‘import flow’) relationship between MVC (and optionally S), as well as the data flow. Importantly, the data flow is reversed as compared to the order of knowledge re each part of the system.

It’s worth making explicit that ‘flow of control’ here isn’t really ‘control flow’ - instead it’s really module import relationships, or alternatively static compile-time binding.

Re services, by “how it speaks” I predominantly mean “how it speaks to the world”, i.e. how we externally reify changes or learn about things outside of it, ‘it’ meaning ’the software being architected’.

Parts in detail

1. View

At the top, the user interacts with the view - in terms of JSX rendered down to DOM, with corresponding interaction handlers (e.g. onClick, etc.), form inputs, etc. When the model changes, components are automatically re-rendered.

On user interaction, the view invokes the controller. Notably, in React, we don’t wait for a return value from these actions; they are often asynchronous. For a form, this might look like invoking a setter for an action atom with the underlying form data.

Views ‘know about’ models in as far as they subscribe to them; however they should delegate any updates to controllers - a controller will invoke domain commands on one or more models.

2. Controller

Controllers translate user intent to one or more model commands. They are ideally an orchestration layer. In Jotai, this takes the form of a write-only ‘action’ atom that predominantly reads/writes from/to other atoms and encapsulates side-effects.

The particularly nice thing about using write-only atoms as a controller layer is that it well-conforms to the Open/Closed principle: your implementation becomes easy (open to) extension via composition, and closed to modification. At least this is true compared to something like Redux, where you pretty much constantly need to be modifying the implementation of your central event reducers.

We are consistently using composition to enable abstraction and extension: React components of course compose declaratively, while Jotai atoms compose functionally.

3. Model

This view renders data from the model, i.e. base Jotai atoms which store data presumably fetched and/or synchronized from some backend. These can either be simple primitive, atomic atoms, or more complicated abstractions that perform caching, staleness re-fetching, listen for / update when server-sent events are received (e.g. WebSocket events), etc. These can be based on HTTP RESTful clients, GraphQL-type APIs, etc - the idea of the model is agnostic to those implementation concerns.

This ’thick model’ idea somewhat diverges from contemporary common usage but arguably better fits the original MVC vision [2].

In Jotai, this will include projection atoms that create filtered or transformed read-only views, to optimize re-rendering and ensure logic remains declarative and reactive.

Part of what makes Jotai effective as a model layer is the global nature of atoms, and the fact that you can create entire hierarchies of derived, auto-updating data. We sort of use the semantics of a spreadsheet - update one value, and you can watch all dependent values update in a cascade.

4. The (optional, auxiliary) “Service layer”

MVC+S is a pretty common extension to the core MVC idea.

  • Service: How it talks to the world

and in specific terms:

  • Service: - Encapsulated TypeScript modules for external effects

Optionally, a service layer can be introduced, which exists below the model and specifies details like network transports (typed http client, websockets etc.), keeping these ideas outside of the model. Whether this is made explicit isn’t an important piece - it opens you up for dependency injection or other benefits arising from a service-oriented architecture, if that’s useful for your domain. However, it’s pretty tangential to MVC itself.

Code sketch

To illustrate this idea concretely, let’s play with a hyper-minimal example.

From a product perspective I’m thinking this: we’re on a user profile page, and we’re displaying the user’s name. We want the name to be editable inline, and when focus changes, we want to persist that updated name against the backend.

View

For the view, we’re displaying the user’s name and allowing edits. On a blur DOM event (focus changed), we call our renameUser controller. We also subscribe to a derived userName via normal reactive React / Jotai semantics.

/* view/UserNameInlineEditor.tsx */

function UserNameInlineEditor() {
  const userName = useAtomValue(userNameAtom);
  const renameUser = useSetAtom(renameUserActionAtom);
  const [draftName, setDraftName] = React.useState(userName);

  React.useEffect(() => setDraftName(userName), [userName]);

  return (
    <input
      value={draftName}
      onChange={(e) => setDraftName(e.target.value)}
      onBlur={() => renameUser(draftName)}
    />
  );
}

Controller

We keep our controllers quite thin - they should ideally translate user intent to one or more model domain-specific commands.

In many cases controllers would be more complex; it’s not always a 1:1 mapping.

/* controller/renameUserActionAtom.ts */

export const renameUserActionAtom = atom(null, (_get, set, name: string) =>
  set(userAtom, { type: "rename", name }),
);

Model

This is your classic ’thick model’, whereby the userAtom is in charge of keeping itself up-to-date, optimistic semantics, rollbacks, etc. For a sketch I wanted to go with something low-abstraction, but likely there would be a bit more sugar here at least to e.g. more cleanly handle commands.

/* model/userAtom.ts */

const _userStateAtom = atom<{ id: string; name: string } | null>(null);

export const userAtom = atom(
  (get) => get(_userStateAtom),
  async (get, set, action: { type: "rename"; name: string }) => {
    const prev = get(_userStateAtom);
    if (!prev) return; // Not logged in; ignore

    switch (action.type) {
      case "rename": {
        const optimistic = { ...prev, name: action.name };
        set(_userStateAtom, optimistic); // optimistic self-update
        try {
          const saved = await api.updateUserName(prev.id, action.name);
          set(_userStateAtom, saved); // reconcile with server
        } catch {
          set(_userStateAtom, prev); // rollback on failure
        }
        return;
      }
    }
  },
);

Final words

When used with purpose, I think React and Jotai together form a compelling way to use MVC in a manner that prioritizes a declarative and monadic code representation.

Refs

[1]: re boring technology

The essay “Choose Boring Technology” was written in 2015, and then perhaps rightly listed NodeJS as a non-boring, innovation-point spending technology. I would say NodeJS is quite boring now however, in a good way.

[2]: re bona-fide, thick models

I espouse here a definition of models that’s more faithful to the original idea from Smalltalk; you often might see a more modern corruption whereby the model is considered to be a ’thin’ or ‘dumb’ data layer, which is updated by ‘fat’ controllers.

Models as a ‘big dumb bag of data’ don’t actually really follow the original spirit of MVC in my view. Instead, in a OOP sort of way, models encapsulate the details of keeping themselves consistent, in a valid state, etc.