React as an Implementation Detail

published: 2019-06-05

This is a loose transcript of a talk I gave a few months ago about the common problems I see repeated across large applications which use vanilla React.

An upfront caveat is target audience. This is primarily targeted at complex, large, and long-lived React applications, but even then: use your best judgement. The complex pieces usually warrant it, the simple ones usually do not. There's nothing original or revolutionary in these ideas, they're often just forgotten.

This talk is about software architecture and how we tend to let React dominate it. The core problem is that we have too much React in our React apps, when instead, we should be pushing those kinds of framework details to the edges of our programs.

What does 'too much' React look like? It looks pretty innocent actually. Usually a short, tight function – the type of thing that would slide through code review with no issue. However, because of its nice appearance, I'll argue that this is one of the most insidious React components that you can write, and you'll find them in just about every code base.


const Headline: React.StatelessComponent = ({stuff}) => (
    <Title>
        {stuff.map(x => x.thing.name).join('-")}
    </Title>
)

What's so wrong with it? Well... Just about everything.

At the top of the list is that it is just doing too many things. SRP and whatnot. It handles doing the data transformation / munging in order to build something "headline-y" (largely a domain idea it shouldn't own) while also dealing with presentation / view level concerns. Flowing from the first issue, is that you have problems of dependency inversion. This little component is structurally coupled to the inner details of your data. Any change to that structure will ripple through your application code.

This blending of worlds is really the core of what I mean by "too much React". When you're writing all of your code in the context of a component, React, the framework, is your architecture, rather than it being a piece of your architecture. This fact generally makes changing things harder.

So the question is then: "does it matter?" What harm actually comes from having React be the main path in your code? In my experience: a complete burn-it-to-the-ground-and-try-again loss of maintainability over the long run. If we look again at that small function, we can pick apart the problems.

const Headline: React.StatelessComponent = ({stuff}) => (
    <Title>
       // structural coupling
        {stuff.map(x => x.thing.name).join('-")}   // SRP violation
       // dependency inversion(ish);
       // high level code knowing concrete details of lower level
    </Title>
)
  1. Is it reusable? Definitely not. It's intimately tied to the context and shape of stuffness. You can't put this elsewhere in your app unless you carry along that stuff with you.
  2. Is it resilient to change? Also nope. It deals with (a) the rules for what is a title, (b) code for munging your current data into said title, and finally (c) actually displaying said title. In short, you've got a whole host of reasons why you'll have to crack into this code again.

The wider world (where the problems be)

Now, the larger problem is that these tiny little components don't exist in isolation. They're situated in a larger world, and as you stack components on top of components, the problems compound.

const MyThingList: React.Stateless = ({things}) => (
    {_.orderBy(things, ['group', 'createdOn']).map(stuff =>
            <Headline stuff={stuff} />
    )}
)

This is another tight looking simple function, only a few real lines of code, but it has all the same problems as the first example. Components are now structurally coupled along different points in a data structure's hierarchy. The outer React wrapper knows about the shape of things and its inner details like sorting keys and criteria, whereas Headline is coupled one layer down to even more inner details.

And the problem expands from there

 <Component1>
   <Component2>
     <Component3>
       <Component4>
         <Component5>
           <Component6>
             <Component7>
               <Component8>
                 <Component9>
                   <Component10>
                     <Component11>
                       <Component12>   <-- (your component probably)
                         <Component13>
                           ...
                            <Component-N>

This style slowly spreads through your code base until even benign changes can require touching multiple pieces of your application.

What's the fix?

Separate the concerns in the architecture.

The goal is to push framework stuff all the way to the edges, move all domain / business related logic into central core layer unconcerned with the dirty outside React world, and keep them strictly separated by an isolation layer.

If you do this, then your components naturally take on a different shape. This is the original example rewritten.

const Headline: React.StatelessComponent = ({title}) => (
    <Title>
         {title}
    </Title>
)

It's visually not that different, but it's mechanically superior in almost every way. It has absolutely zero knowledge of external structures. It takes a pre-formatted string and poops it onto the screen. All the other concerns (i.e. what IS a title, where do I get the data, etc..) have been completely removed. This is where we wanna be for most of our React components. This is the glory land. I like to call these kinds of components "context free." They're not coupled to anything in your application. They could be plucked out and put into an entirely different project with zero fuss.

Putting it into practice

So, what does this all look like in practice?

We'll walk through a tiny example app. A basic contacts manager that lets you add / remove people you know.

NO REACT!

The most important rule at this point is that we and not going to be building anything with React! We are going to model the domain and business rules independently of any framework. React is not our application, just a piece within it, so we don't need it yet.

Step 1. Data Modeling

The general rule of thumb is that Data > Functions > Components. Data is self-describing, works everywhere, can be serialized, shipped across a wire, used in any language, etc.. in short, plain ol' data is awesome. As such, that's where we'll begin.

Basic Model:

state = {
  form: {...},
  modal: {
    title: 'Add new Contact',
    visible: false,
  },
  contacts: []
}

We going to have a form, there will be a modal and a contacts list. This is our domain. It should represent everything interesting or novel about the app, and all behaviors / user interactions should be applied to this model.

Detailed Form:

  form: {
    fullName: {
       name: "fullName",  
        value: "",
        type: "text",  
        placeholder: 'Your Name',
        init: '',
        error: ""
    }

Zooming in on the form model itself. Note that each field blob is rather detailed. It stores not only its value, but also its html type, placeholder text, empty states, etc.. the reason for this is twofold. 1. when you model as much of your domain in plain data as possible opportunities for generic re-usable functions become more aparrent (e.g. rendering an entire form via map rather than hard-coding), and 2. the fact that it is self describing and all in one spot makes getting a quick overview of all the important details super easy for both yourself and those working on the code after you. The goal here is to move ideas that are usually spread throughout the codebase into one single breadrock core.

So, we have our data. Now the question is, what events are relevant to it?

Step 2. Modeling Events:

Notes:

  • We're still not writing React!
  • You do not need click buttons to test behaviors!
  • You do not need things on the screen!

Events are what transition your domain model to a new state. They represent the core behaviors which your application can do. They should all be simple, pure, and composable.

Contact List:

The obvious ones for our simple example are the basic CRUD ones: add, remove, update.

addContact :: State -> Event -> State
removeContact :: State -> Event -> State
updateContact :: State -> Event -> State

Note that they all comply to a standard interface. They take the full current state of the world, an event, and return an updated next state. This makes them easy to test, easy to integrate with React (or any of its common helpers!), and easy to compose!

Example implementation:


const addContact = (state, contact) => (
  _.assign({}, state, {contacts: [...state.contacts, contact]})
);

Where does that contact data actually come from? It doesn't matter! The goal of this is to express your logic independent of the outside world. Meaning, no React, no network requests, no database access, etc.. We're dealing only with plain simple data + functions.

The benefits of living in this world as long as possible are huge. For one, testing becomes dead simple. No soul sucking enzyme shallow, Mount, or Render nonsense in your tests. You can test your entire domain -- the complete core of your application's logic -- using any vanilla testing library with a simple expect(myFunction(oldState)).toEqual(newState).

REPL:

The other large benefit is by and large my favorite (but admittedly not for everyone): REPL driven development.

When your state and domain functions aren't tied to any specific framework, you're free to mount it anywhere. You can throw your state into dev tools, mount your function's namespace, and develop your core functionality free of any transpile loops, page refreshes, or button clicking. The REPL makes trying out ideas crazy easy, and unlike compile time tests, you keep a full history as you go. Meaning, you can scroll up and see that idea you had a second ago and how it affected your state. The immediate feedback vastly speeds up development and exploring large state trees is a breeze when you have Chrome/Firefox's dev tools at your finger tips.

Finishing up the Domain Interactions

Rounding out the set of events which will affect our domain. We'll have the following functions.

Add/ Edit Contact Form:


initForm :: State -> Event -> State
initFormWith :: State -> Event -> State
updateField :: State -> Event -> State
validate :: State -> Event -> State

Modal

showModal :: State -> Event -> State

Step 3. The Application tier - composite behaviors

As a point of clarity, I use "tier" purely conceptually, not as a formal separation

Reminder: still no React!

Here is where you coordinate larger behaviors that affect your domain. For instance, what should happen when the user clicks the "Add New" button? We'll have to open the modal as well as initialize it to a known empty state.

const openCreateModel = (state, event) => (
  compose(initForm, openModal)(state, event)
)

Again, these are still just simple plain functions. We can still be hanging out in the REPL, writing tests, or whatever. We're still just focused on gluing together simple behaviors into larger ones.

const openEditModel = (state, contactInfo) => (
    initFormWith(openModal(state, true), contactInfo)

)

The main goal here is to be high level and declarative.

Step 4. Presentation Tier

We now have data, and a bunch of functions which operate on that data. However, we can't start building our UI on that raw data – we'd end up right back where we started! We need a layer of isolation between our domain and our framework to avoid the original coupling problem. Something which can own the business rules of how data gets transformed before being consumed.

I call this a presentation tier, but it's sometimes called Projections or Subscriptions. Regardless, they all exist to accomplish the same thing: give safe, stable views into your data.

Presentation Examples:

const fullName = (contact) => (
    `${contact.firstName} ${contact.lastName}
);

const orderedContacts = (contacts) => (
    _.orderBy(contacts, ['createdOn'])
)

const buildTitle= (stuff) => (
    // this is where we moved it! ^_^
    stuff.map(x => x.thing.name).join('-")
)

This is the core thing that protects you from brittle, hard to change React code. The benefit here is that, again, you're still dealing with plain functions on plain data. A function that takes an A and returns a B can be composed with any other function that takes a B! This is not true of Components. String -> Component is, essentially, a terminal signature from the point of view of data processing. You can't (sanely) compose it with any other function. So in addition to isolation, the presentation tier gives you options and openness.

As a super, super rough heuristic, Law of Demeter's “two dots bad” rule can be used for a loose guide on when to put data access behind a projection. If you find yourself handing a React component a data structure that they're having to drill into to get a value, that's a good time to take pause and ask if the view code should actually know about those details. Of course, sometimes it can't be avoided, or just Makes Sense, but in the general case, it's a good rule of thumb for reflecting on abstractions.

Step 5: Putting it all back together: React

We've modeled our core domain and its behaviors as pure functions, built up large user interactions from small pieces, and created ready-to-consume projections for our data model. Finally, it is time to lift the embargo on React and start integrating our work!

Domain Side:

Your main React containers largely become just delegation controllers. They exist to pass user events down into the domain layer for processing / producing the next state.


Class MyContainer extends React.Class {
  handleNewContact(event) {
      this.setState(state => domain.openCreateModal(state, event))

  }


  handleEditContact(event) {
    this.setState(state => domain.openEditModel(state, event))

  }

 

  render() {...}
}

View Side:

If you've followed everything up to this point, the view side mostly takes care of itself. We end up back at the original example.

const Headline: React.StatelessComponent = ({title}) => (
    <Title>
         {title}
    </Title>
)

The presentation tier keeps everything important out of React land thus keeping things light weight, free of external coupling, and truly reusable across your application.

Closing Remarks

In the end, I want to stress that this layered architecture is not free. There is overhead involved in the process, the management of the various tiers, wiring together the delegations, etc.. as with all software architectures, it shouldn't be blanket applied. This is here to address large, long lived "E N T E R P R I S E" applications built on React. The complexity of your implementation should mirror the complexity of your domain. A vast majority of React apps are little more than fetching data from an API and splatting it onto the screen. This is not the situation where you should apply formal tiered development approaches. It is for the hairy, complex, and often changing bits of the app where long-term maintainability matters that I advocate the above approach.