React architecture

Originally published Jun 29, 2017·Tagged #web-development, #react, #software-architecture

At work, I'm currently working on an internal application that's close to its release. The app uses React and Redux, as well as React-Bootstrap. This is the largest frontend project by far that I've ever worked on, and even more intimidating is the fact that I was responsible for designing the architecture of the app.

I've started to notice a couple of habits I've developed that have generally led to higher-quality code that I rarely have to go back and redo (unless the requirements change, of course).

Store data with as few modifications as possible

Let's consider a hypothetical web app similar to Goodreads, where users want to track books they've read and get recommendations. Suppose I were working with a backend developer whose /api/v1/books endpoint returned objects of the following shape:

{
  "bookUuid": "00000000-0000-0000-0000-000000000000",
  "bookTitle": "Expert Excuses for Not Writing Unit Tests",
  "bookAuthor": "The Practical Dev",
  "bookPublisher": "O RLY?",
  "bookWebsite": "https://github.com/thepracticaldev"
}

However, this annoys me. So I decide that when I load the data into the application, I'm going to transform it into the following format before persisting in the Store:

{
  "id": "00000000-0000-0000-0000-000000000000",
  "title": "Expert Excuses for Not Writing Unit Tests",
  "author": "The Practical Dev",
  "publisher": "O RLY?",
  "site": "https://github.com/thepracticaldev"
}

No big deal, right? I just have to make sure that any updates the user sends to the server get translated back to the format that the server expects.

However, now I must also make sure there are no other API calls that return a Book. For instance, if the /api/v1/friends returns a list of friends along with their favorite book included inline, I have to make sure to map that response into the local format correctly.

The easiest solution to this problem is to simply work with the format the server returns. However, sometimes you need a different format on the frontend. Maybe you're pulling information from different sources and you want to display them all together in the same component. Here's where selectors come in handy.

Tip

Rule #1: Make your code as simple as you possibly can.

Corollary: Never do with state what you can do with selectors.

State is finicky. Every time your program carries state, you introduce the possibility of state bugs. Conversely, if you remove state from your application, you categorically prevent an entire (very common) class of bugs.

Thinking of problems as "state bugs" is a pretty handy analogy for some "IRL" issues as well. Some "blue laws" are state bugs of government. A hangover is a state bug of your body, etc.

Looking back at our example, suppose the Store state looked like this:

{
  "booksById": {
    "00000000-0000-0000-0000-000000000000": {
      "bookUuid": "00000000-0000-0000-0000-000000000000",
      "bookTitle": "Expert Excuses for Not Writing Unit Tests",
      "bookAuthor": "The Practical Dev",
      "bookPublisher": "O RLY?",
      "bookWebsite": "https://github.com/thepracticaldev"
    }
  }
}

If you really needed the simpler format, you could write a selector that gave you what you wanted:

import {createSelector} from 'reselect';
import get from 'lodash/get';

export const selectBooksById = (state) => state.booksById;

export const selectBookById = (id) => createSelector(
  selectBooksById,
  (booksById) => {
    const maybeBook = get(booksById, [id]);
    if (!maybeBook) return null;
    const {
      bookUuid: id,
      bookTitle: title,
      bookAuthor: author,
      bookPublisher: publisher,
      bookWebsite: site,
    } = maybeBook;
    return {id, title, author, publisher, site};
  }
);

Keep the shape of your State as "flat" as possible

Initially when I designed the State object, I tended to "nest" related information. For example, in the past I might've designed a Store state that looked something like this:

const state = {
  books: {
    // Total number of books in the database.
    totalBooks: 65321,
    booksById: {
      '00000000-0000-0000-0000-000000000000': {
        // book data...
      },
      '00000000-0000-0000-0000-000000000001': {
        // different book data...
      }
    }
  }
};

I might've written a complicated reducer that looked something like this:

// The implementation of `booksByIdReducer` is not important here;
// what counts is that it's doing something complicated that we want
// to handle in a different file.
import {booksByIdReducer} from './booksByIdReducer';

function bookReducer(state = {booksById: {}}, action) {
  switch (action.type) {
    case GET_TOTAL_BOOK_COUNT_SUCCESS:
      return {
        ...state,
        totalBooks: action.payload,
      };
    case FETCH_BOOK_SUCCESS:
    case UPDATE_BOOK_AUTHOR_SUCCESS:
    case UPDATE_BOOK_TITLE_SUCCESS:
    case DELETE_BOOK_SUCCESS:
    case RECOMMEND_BOOK_SUCCESS:
      return {
        ...state,
        booksById: booksByIdReducer(state.booksById, action),
      };
    default: return state;
  }
}

export default bookReducer;

That large set of case statements that do nothing but fall through to a subreducer are a big flashing warning sign that this code will be a pain to deal with. If you suddenly need a new action type to be handled in booksByIdReducer, you'll not only have to implement it in that method, but also "thread it through" the parent reducer (bookReducer).

Instead, make use of combineReducers wherever possible, and your reducers will become trivial:

import {combineReducers} from 'redux';
import {booksByIdReducer} from './booksByIdReducer';

function totalBooksCountReducer(state = null, action) {
  if (action.type === GET_TOTAL_BOOK_COUNT_SUCCESS) {
    return action.payload;
  }
  return state;
}

const bookReducer = combineReducers({
  totalBooks: totalBooksCountReducer,
  booksById: booksByIdReducer,
});

export default bookReducer;

Testing this would be a breeze! You wouldn't even have to bother testing the exported bookReducer, since combineReducers already has tests on it.

Rule of thumb: When writing reducers, use combineReducers as much as possible, and maybe a little bit more.

All objects of the same type should be in the same place

This one is sort of a natural consequence of the previous guideline, but the "recipe" for dealing with it is particularly useful.

Sometimes you might end up with a service response that makes it really tempting to store "nested" objects. For instance, consider that there might be a /api/v1/friends endpoint that returns something like this:

[{
  "userUuid": "11110000-0000-0000-0000-000000000000",
  "userName": "Charles",
  "favoriteBook": {
    "bookUuid": "00000000-0000-0000-0000-000000000000",
    "bookTitle": "Expert Excuses for Not Writing Unit Tests",
    "bookAuthor": "The Practical Dev",
    "bookPublisher": "O RLY?",
    "bookWebsite": "https://github.com/thepracticaldev"
  }
}, {
  "userUuid": "22220000-0000-0000-0000-000000000000",
  "userName": "Amy",
  "favoriteBook": {
    "bookUuid": "00000000-0000-0000-0000-000000000001",
    "bookTitle": "Writing Useless Git Commit Messages",
    "bookAuthor": "The Practical Dev",
    "bookPublisher": "O RLY?",
    "bookWebsite": "https://github.com/thepracticaldev"
  }
}]

Note that we have two data types here: User objects, each of which contains a Book object. It's very tempting to shove these objects into the store as-is:

import fetch from 'isomorphic-fetch';
import {fetchFriendsSuccess} from './friendActions';

export function loadFriends() {
  return async function performFriendsLoad(dispatch, getState) {
    // Please handle errors in your real-world code!
    const response = await fetch('/api/v1/friends');
    const friendsList = await response.json();

    // The reducer for `fetchFriendsSuccess` simply inserts the given `User`
    // objects into the store by their ID.
    dispatch(fetchFriendsSuccess(friendsList));
  }
}

However, it's much more useful to split them apart by type:

import fetch from 'isomorphic-fetch';
import {fetchFriendsSuccess, fetchBooksSuccess} from './friendActions';

export function loadFriends() {
  return async function performFriendsLoad(dispatch, getState) {
    const response = await fetch('/api/v1/friends');
    const friendsList = await response.json();

    const friendsToAdd = [];
    // Keep a Map of books to add so we don't create duplicates.
    const booksToAdd = new Map();

    friendList.forEach((friend) => {
      const {
        userUuid,
        userName,
        favoriteBook: {
          bookUuid: favoriteBookUuid
        }
      } = friend;
      friendsToAdd.push({userUuid, userName, favoriteBookUuid});

      if (!booksToAdd.has(favoriteBookUuid)) {
        booksToAdd.set(favoriteBookUuid, friend.favoriteBook);
      }
    });

    dispatch(fetchFriendsSuccess(friendsToAdd));
    dispatch(fetchBooksSuccess([...booksToAdd.values()]))
  }
}

But what if you want to use the nested object in a component? Remember Rule #1: use selectors!

import {createSelector} from 'reselect';
import get from 'lodash/get';

export const selectBooksById = (state) => state.booksById || {};
export const selectFriendsById = (state) => state.friendsById || {};

export const selectFriendsFull = createSelector(
  selectFriendsById,
  selectBooksById,
  (friends, books) => {
    const result = {};
    Object.keys(friends).forEach((friendUuid) => {
      const friend = friends[friendUuid];
      result[friendUuid] = {
        ...friend,
        favoriteBook: books[friend.favoriteBookUuid],
      };
    })
    return result;
  }
)

Now, if a book gets updated within the application, you don't have to worry about keeping it "in sync" across the whole store. It's only used in one place.

Be wary of arbitrary keys

Tip

Rule #2: Never nest arbitrary keys on consecutive levels, or allow arbitrary keys at the top level.

I call this the "Docker Compose" problem. Version 1 of docker-compose.yml looked something like the following:

# Database service
db:
  build: ./mysql
  expose:
    - 3306
  ports:
    - "3306:3306"
  environment:
    MYSQL_ROOT_PASSWORD: admin
# PHP My Admin service
db-admin:
  image: phpmyadmin/phpmyadmin
  links:
    - db
  ports:
    - "8081:80"

For Version 2, there was some extra information that needed to be added to describe the configuration as a whole. However, there was no way to add those fields into the existing format, since the top level could contain any keys, and they might break someone's configuration if they simply decided that some of the keys might be settings. For example, consider an option called network. If someone's configuration defined a service called network, then it would break when they upgraded. Therefore, the designers had to introduce a new version key at the top level. If the version was set to 2, then the top-level keys were no longer interpreted as service names but as fixed config values:

version: 2
# Set specific config options.
network:
  driver: overlay
# `services` key safely contains the arbitrary keys
# from the previous version.
services:
  db:
    # ... Same as before ...
  db-admin:
    # ... Same as before ...

This issue can arise anywhere a data structure is serialized, including config files and API requests or responses. This also includes the Redux store, since generally it's a good idea to keep the store state in a serializable format.

To avoid running into a similar problem, I follow these three rules:

  • Rule 2.1: Never allow arbitrary keys at the top level.
  • Rule 2.2: Never nest an object with arbitrary keys inside another object with arbitrary keys.
  • Rule 2.3: For the purposes of this rule, an array counts as an object with arbitrary keys.

Rule 2.3 is important addition. Consider our API response for /api/v1/friends:

[{
  "userUuid": "...",
  "userName": "Charles",
  /* ... */
}, {
  "userUuid": "...",
  "userName": "Amy",
  /* ... */
}]

If we get a new requirement that we need to paginate this response, we'd need a totalFriendsCount key or similar at the top level. Where would we put it? If we turn the array into an object, we'd have to rewrite the Redux action that handles this call. Better to keep a fixed top-level key:

{
  "friends": [{
    "userUuid": "...",
    "userName": "Charles",
    /* ... */
  }, {
    "userUuid": "...",
    "userName": "Amy",
    /* ... */
  }]
}

Now, when we add totalFriendsCount, any code referring to response.friends will continue to work as expected.

Code should be "too small to fail"

During the 2008 financial crisis, the popular term used to describe the banks was "too big to fail". To me, that seems absurd: complexity generally increases with size, so a massive organization could potentially manage to create some serious problems.

The best code is code that's "too small to fail": so mind-numbingly simple that it has to work.

Consider a hypothetical component that displays the current time and allows the user to select different time zones. It's very tempting to go "all-in" and write one single component that handles all of the functionality: fetches the time zone data from a service, renders the dropdown, handles change events in the dropdown, and formats the time for display. As described, this functionality would probably be pretty easy to implement. However, there are a couple issues that might crop up:

  • Requires complex unit tests. Since it's all one component, your unit tests will have to have extensive setup: mock out network calls, model and test internal state, simulate DOM interactions, etc.
  • Overall code is less robust against requirements changes. There's an old joke that programmers enjoy cooking meals for the sole reason that it allows them to take a task from start to finish with no changes in requirements. Code written as a monolith means that every part is likely to depend on every other part, meaning a change that looks simple on the surface requires lots of time-consuming tweaking and fine-tuning to make sure nothing breaks. Also, a large component necessarily has a large number of implicit assumptions made during its creation, and if these assumptions change then it's likely to trigger a bug hunt.
  • Code is less readable. Yes, you can write self-documenting code or add extensive comments to make it easier to follow the flow of the logic, but it's even easier when there's little or no logic to follow.

To address these issues, we might break our WorldClock component into several subcomponents:

  • A WorldClock component to wrap the subcomponents, provide an API for consumers, and to perform network calls. Subcomponents:
    • TimeZoneSelect, which accepts a list of time zones as a prop and has an onChange callback for when the user makes a selection
    • TimeDisplay, which accepts a time zone and a format (such as HH:MM:SS) as an argument, and renders a ticking clock in the DOM with the time displayed in the correct format and timezone.

Because the unit tests are much simpler for these, it makes it much more likely that the developer will think of new edge cases while writing them. This has come in handy, in my experience.

Tip

Corollary: If you find yourself adding a flag that switches a component between two different behaviors, consider writing it as two separate components.

Don't be afraid of higher-order components

The project I'm working on for my job pulls data from different sources and displays it as cells in a table view. I wrote cell-value fetching as a big, complicated plugin for the table library we're using, but there's a much simpler way I should have done it: with higher-order components.

Consider our books application. It might need to pull book data from one source and ratings from another source and display it in a table. The goal of the component we're writing here is to create a table that accepts a list of book IDs and displays a scrollable table with the title (from the books endpoint) and rating (from the ratings endpoint) of each book.

Here's how we might implement a higher-order component (HoC) that transparently adds the book-data network calls. The bookData prop here is a map of book IDs to objects representing the loaded data.

import React from 'react';

// Always use an outer function in your HoC so you can pass settings.
// You may not need it now, but please leave the door open!
function WithBookData() {
  return function createWrappedComponent(BaseComponent) {
    const {name, displayName = name} = BaseComponent;
    
    class WrappedComponent extends React.Component {
      // Create a display name for easier debugging.
      static displayName = `WithBookData(${displayName})`;
      
      
      // Hash of {[bookId]: bookInfo}.
      // Note that this breaks the "arbitrary keys" rule, as the top-level keys
      // of the state object are book IDs which may be seen as arbitrary
      // strings. However, 
      state = {};
      
      // There are a lot of edge cases here; the implementation here isn't
      // important. Focus on the fact that it's checking which items need data
      // and fetching them.
      componentWillReceiveProps({bookData}) {
        if (bookData !== this.props.bookData) {
          Object.keys(bookData).forEach((bookId) => {
            const oldBookData = this.props.bookData[bookId];
            if (bookData[bookId] !== oldBookData) {
              fetch(`/api/v1/books/${bookId}`)
                .then(response => response.json())
                .then((bookInfo) => this.setState({
                  [bookId]: bookInfo
                }));
            }
          })
        }
      }
      
      // Create a new "merged" `bookData` prop that merges in the passed data
      // with what was fetched from the service.
      getMergedData() {
        const {bookData: propsBookData} = this.props;
        const result = {};
        Object.keys(propsBookData).forEach((bookId) => {
          result[bookId] = {
            ...propsBookData[bookId],
            bookData: this.state[bookId],
          };
        });
        return result;
      }
      
      render() {
        const bookData = this.getMergedData();
        return (
          <BaseComponent
            {...this.props}
            bookData={bookData}
          />
        );
      }
    }
    
    return WrappedComponent;
  }
}

If you then also wrote a WithRatingsData component, your BookRatingsTable component implementation might look something like this:

import {compose} from 'redux';
import BaseRatingsTable from './BaseRatingsTable';
import WithBookData from './WithBookData';
import WithRatingsData from './WithRatingsData';

export default compose(
  WithBookData(),
  WithRatingsData()
)(BaseRatingsTable);

Unit tests for each higher-order component will be simple and straightforward, and you'll enjoy a much greater degree of confidence in your code.