Back to all posts

Best practices for building a large scale react application

Posted by Aman Khalid on May 30, 2019
  1. Start on the board
  2. Actions, Datasource and API
  3. Redux Integrations
  4. Dynamic UI at scale

This article describes the steps to building a large scale React application. While making a Single Page App with React, it is very easy for your code-base to become unorganized. This makes the app hard to debug and even harder to update or to extend the codebase.

There are many good libraries in the React ecosystem that can be used to manage certain aspects of the app, this article covers some of them in depth. Other than that, it lists some good practices to follow from the beginning of the project if you have scalability in mind. With that being said let’s head to the first step - how to plan ahead of time.

Start on the board

Most of the time developers skip this part as it has nothing to do with actual coding itself, but its importance cannot be understated, which you’ll see in a bit.

Application Planning Phase - Why do it?

While developing software there are many moving parts that the developers have to manage. It is very easy for things to go wrong. With so many uncertainties and roadblocks, one thing that you don't want is exceeding the time taken.

This is something the planning phase can save you from. In this phase, you lay down every detail your app would have. It is much easier to predict the time it will take to build these small individual modules in front of you as compared to picturing the whole thing in your head.

If you have multiple developers working on this large project (which you will), having this document will make it much easier to communicate with each other. In fact, things from this doc can be assigned to developers, this will make it much easier for everyone to know what others are working on.

And lastly, because of this doc, you’ll have a very good sense of your own progress on the project. It is very common for developers to switch from one part of the app they’re working on to the other and come back to it much later than they would like to.

Step 1: Views and Components

We need to determine the look and functionality of each view in the app. One of the best approaches is to draw each view of the app either using a mockup tool or on paper, this will give you a good idea of what information and data you're planning to have on each page.

undefined

Source

In the mockup above, you can easily see the child and parent containers of the app. Later on, the parent containers of these mockups will be the pages of our app and the smaller items will land in the component folder on our application. After you have drawn out the mockups, write the names of pages and components within each of them.

Step 2: In-app actions and events

After deciding on components, plan out what actions would be performed within each of them. Those actions would later be dispatched from within these components

Consider an e-commerce site with a list of featured products on the homescreen. Each item on that list would be an individual component in the project. Let the name of the component be ListItem.


undefined

Source

So, in this app the action performed by the Product section component is getItems. Some other actions on this page may include getUserDetails, getSearchResults, etc.

The point is to observe the actions at each component or interactions of the user with the app’s data. Wherever data is being modified, read or deleted note that action down for each page.

Step 3: Data and models

Every component of the app has some data associated with it. If the same data is being used by multiple components of the application, it will be part of the centralized state tree. The state tree will be managed by redux.

This data is used by multiple components, hence when it is changed at one place other components reflect the changed value too.

Make a list of such data in your application as this will constitute the models of the app, and based on these values you'll create your app’s reducers.

products: {
  productId: {productId, productName, category, image, price},
  productId: {productId, productName, category, image, price},
  productId: {productId, productName, category, image, price},
}

Consider the example of the e-commerce store above. The type of data being used by the featured section and the new arrivals section is the same, which is products. So that would be one of the reducers of this e-commerce app.

After documenting your plan of action, it's time to look at some details necessary to setup the app’s data layer, covered in the next section.

Actions, Datasource and API

As the app grows, it is very common for the redux store to have redundant methods and improper directory structure, and it becomes hard to maintain or update.

Let’s see how we can realign some things to make sure the code of the redux store stays clean. A lot of trouble can be saved by making modules more reusable from the very beginning, although this can seem like trouble at first.

API design and client apps

While setting up the datastore, the format in which data is received from the API affects the layout of your store a lot. Often times it is necessary to format it before it can be fed to the reducers.

There's a lot of debate surrounding what should and shouldn't be done while designing API. Factors such as Backend Framework, Size of the APP further affect how you design your API.

Just like you would in a backend app, keep utility functions like formatters and mappers in a separate folder. Make sure these functions are free of side effects - See Javascript Pure Functions

export function formatTweet (tweet, author, authedUser, parentTweet) {
  const { id, likes, replies, text, timestamp } = tweet
  const { name, avatarURL } = author

  return {
    name,
    id,
    timestamp,
    text,
    avatar: avatarURL,
    likes: likes.length,
    replies: replies.length,
    hasLiked: likes.includes(authedUser),
    parent: !parentTweet ? null : {
      author: parentTweet.author,
      id: parentTweet.id,
    }
  }
}


In the above snippet, formatTweet function inserts a new key parent to the tweet object of the frontend app and returns data based on parameters, without affecting outside data.

You can take it a step further by mapping the data to a predefined object whose structure is specific to your frontend app and has validations for some keys. Let's talk about the parts that are responsible for making the API calls.

Datasource design patterns

The parts I describe in this section will be directly used by redux actions, to modify the state. Depending on the size of the app (and also the time you have) you can go about setting the datastore in one of the two ways.

  • Without Courier
  • With Courier

Without Courier

Arranging your datastore this way requires you to define GET, POST, PUT requests separately for each model.

undefined

In the above diagram, each component dispatches actions that call methods of the different datastores. This is what the updateBlog method of the BlogApi file would look like.

function updateBlog(blog){
   let blog_object = new BlogModel(blog) 
   axios.put('/blog', { ...blog_object })
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });
}

This approach saves time... At first, it also lets you make modifications without worrying too much about side effects. But there’ll be a lot of redundant code, and performing bulk updates is time-consuming.

With Courier

This approach makes things easier to maintain or update in the long run. Your codebase stays clean as you are saved the trouble of making duplicate calls via axios.


undefined

However, this approach takes time to set up initially.  You have less flexibility.  That’s kind of a double edged sword because it prevents you from doing something unusual.

export default function courier(query, payload) {
   let path = `${SITE_URL}`;
   path += `/${query.model}`;
   if (query.id) path += `/${query.id}`;
   if (query.url) path += `/${query.url}`;
   if (query.var) path += `?${QueryString.stringify(query.var)}`;
   

   return axios({ url: path, ...payload })
     .then(response => response)
     .catch(error => ({ error }));
}


Here’s what a basic
courier method would look like, all the API handlers can simply call it, by passing the following variables:

  • A query object that would contain the URL related details like name of the model, query string and others.
  • Payload, which contains request headers and body.

API calls and In-app actions

While working with redux, one thing that stands out is the usage of predefined actions. It makes the changes in data throughout the app, much more predictable.

Even though it may seem like a lot of work - to define a bunch of constants in a large app, Step 2 of the planning phase makes it a whole lot easier.

export const BOOK_ACTIONS = {
   GET:'GET_BOOK',
   LIST:'GET_BOOKS',
   POST:'POST_BOOK',
   UPDATE:'UPDATE_BOOK',
   DELETE:'DELETE_BOOK',
}

export function createBook(book) {
   return {
      type: BOOK_ACTIONS.POST,
    	book
   }
}

export function handleCreateBook (book) {
   return (dispatch) => {
      return createBookAPI(book)
         .then(() => {
            dispatch(createBook(book))
         })
         .catch((e) => {
            console.warn('error in creating book', e);
            alert('Error Creating book')
         })
   }
}

export default {
   handleCreateBook,
}


The above code snippet shows an easy way to blend in methods of our data source
createBookAPI with our redux actions. The handleCreateBook method can be passed to the dispatch method of redux safely.

Also, note that the above code lives in the actions directory of our project, we can similarly create javascript files containing the name of the actions and handlers for various other models of our app.

Redux Integrations

In this section, I’ll discuss how the functionality of redux can be extended to take care of more complex operations of the app, systematically. These are the things that if implemented poorly can break the pattern of the store.

Javascript generator functions are able to solve many problems associated with async programming since they can be started stopped at will. The Redux Sagas middleware uses this concept for managing impure aspects of the app.

Managing impure aspects of the app

Consider a scenario. You are asked to work on an app for a real-estate discovery. The client wants to move to a new and better website. The REST API is in place, you’ve been given the design of each page on Zapier and you have drafted a plan as well, however calamity strikes.

The CMS client has been used by their company for a very long time, they're very familiar with it, and thus do not wish to change to a new one just to write blogs.  In addition, copying all the old blogs would be a hassle.

Fortunately, the CMS has a readable API that gives you the blog content.  Unfortunately, if you've written a courier, the CMS API is on another server that has different grammar.

This is kind of an impure aspect of the app since you're accommodating a new API used for simply fetching the blogs. This can be handled by using React Sagas.

Consider the following diagram.  We are fetching blogs in the background using Sagas.  This is what the entire interaction would look like.

undefined

Here the component makes a dispatch action say GET.BLOGS and in an app using redux middleware that request will be intercepted and in the background, your generator function will fetch the data from datastore and update redux.

Here’s an example of what a generator function of your blog sagas would look like. You can also use sagas to store user data (eg auth tokens) as it is another impure action.

...

function* fetchPosts(action) {
 if (action.type === WP_POSTS.LIST.REQUESTED) {
   try {
     const response = yield call(wpGet, {
       model: WP_POSTS.MODEL,
       contentType: APPLICATION_JSON,
       query: action.payload.query,
     });
     if (response.error) {
       yield put({
         type: WP_POSTS.LIST.FAILED,
         payload: response.error.response.data.msg,
       });
       return;
     }
     yield put({
       type: WP_POSTS.LIST.SUCCESS,
       payload: {
         posts: response.data,
         total: response.headers['x-wp-total'],
         query: action.payload.query,
       },
       view: action.view,
     });
   } catch (e) {
     yield put({ type: WP_POSTS.LIST.FAILED, payload: e.message });
   }
 }
...

It listens to actions of type WP_POSTS.LIST.REQUESTED and then fetches the data from API.  It dispatches another action WP_POSTS.LIST.SUCCESS which then updates the blog reducer.

Reducer Injections

For a large app, planning out each and every model beforehand is not possible, moreover as the app grows this technique saves a lot of manhours, and lets the developer add new reducers without rewiring the whole store.

There are libraries that let you do that off the bat, but I like this approach better because you have the flexibility to integrate it with your old code without too much rewiring.

This is a form of code splitting and is being actively adopted by the community. I’ll be using this code snippet as an example to show what a reducer injector looks and how it works. Let’s see how it is integrated with redux first.

...

const withConnect = connect(
 mapStateToProps,
 mapDispatchToProps,
);

const withReducer = injectReducer({
 key: BLOG_VIEW,
 reducer: blogReducer,
});

class BlogPage extends React.Component {
  ...
}

export default compose(
 withReducer,
 withConnect,
)(BlogPage);


The above code is a part of BlogPage.js which is the component of our app.

Here instead of connect we are exporting compose which is another function from redux library, what it does is, it lets you pass multiple functions that would be read from left to right or bottom to top.

 All compose does is let you write deeply nested function transformations without the rightward drift of the code. Don't give it too much credit!

(From Redux Documentation)

The leftmost function can receive multiple parameters, but only one parameter is passed on to the functions after that. Eventually, the signature of the right-most function will be used.  That is the reason we passed withConnect as the last parameter, so that compose can be used just like connect.

Routing and Redux

There is a range of tools that people like to use for handling routing in their app, but in this section I’ll stick to react router dom and extend its functionality, to work with redux.

The most general approach of using react router is to wrap the root component with BrowserRouter tag and wrap the child containers with the withRouter() method and exporting them [example].

This way the child component receives a history prop with some properties that are specific to the user’s session and some methods which can be used to control navigation.

Implementing it this way can cause issues in large apps as there is no central view of the history object. In addition, components that are not rendered via the route component like this cannot access it:

<Route path="/" exact component={HomePage} />

To overcome this, we’ll use the connected react router library, which lets you easily use routing via dispatch methods. Integrating this requires making some modifications, namely creating a new reducer specifically for routes (obviously) and adding a new middleware.

After initial setup it can be used through redux.  In-app navigation can simply be done by dispatching actions.

To use connected react router in a component we can simply map the dispatch method to the store, according to your routing needs. Here is a snippet that shows usage of connected react router(make sure initial setup is complete).

import { push } from 'connected-react-router'
...

const mapDispatchToProps = dispatch => ({
  goTo: payload => {
    dispatch(push(payload.path));
  },
});

class DemoComponent extends React.Component {
  render() {
    return (
      <Child 
        onClick={
          () => {
            this.props.goTo({ path: `/gallery/`});
          }
        }
      />
    )
  }
}

...


In the code sample above the
goTo method in dispatches the action which pushed the URL you desire in the history stack of the browser. Since the goTo method has been mapped to the store it is passed a prop onto the DemoComponent.

Dynamic UI at scale

At times, despite having an adequate backend and core SPA logic, some elements of the user interface end up being detrimental to the overall user journey due to hacky implementation of components, that seem very basic at the surface. In this section, I discuss best practices to implement some widgets that get tricky as the app scales.

Soft Loading and Suspense

The best thing about the asynchronous nature of javascript is that you get to utilize the full potential of the browser. Not having to wait for processes to finish before queuing new ones is truly a blessing. However, as developers we have no control over the network and the assets loaded over them.

The network layer, in general, is expected to be unreliable and fault-prone.  No matter how many quality checks your single page application passes, there are some things like connectivity, response times, etc that are just not in our control.

But software developers refrain from using the phrase “that’s not my job” and have developed elegant solutions to cope with these type of issues.

In certain parts of the frontend-app, you would want to display some fallback content (something lighter than what you’re trying to load) so the user doesn't see the jittering after things are loaded or worse, this sign:

Broken Link
Broken Image

React suspense lets you do just that. You can display some sort of spinner while content is being loaded. Although this can be done manually by changing the isLoaded prop to true, using suspense is much cleaner.

Learn more about how you can use it here. In this video, Jared Palmer introduces React suspense and some of its functionalities in a real app.

undefined

Without Suspense

Adding suspense to your components is much easier than managing an isLoaded object in your global state.  We start by wrapping the parent App container with React.StrictMode. Make sure none of the React modules being used in your app are deprecated.

<React.Suspense fallback={<Spinner size="large" />}>
  <ArtistDetails id={this.props.id}/>
  <ArtistTopTracks />
  <ArtistAlbums id={this.props.id}/>
</React.Suspense>


The components wrapped within React.Suspense tags load the component specified in its fallback prop while the main content is being loaded. Make sure the component in the fallback prop is lightweight.

undefined

With Suspense

Adaptive Components

In a large frontend app. Repeating patterns start to emerge, even though they might not be as apparent to start.  You can't help but feel like you've done this before.

For example, there are two models in an app you're building: race tracks and cars. The list page for cars has square tiles each with an image and a little description.

However, the list page for the race tracks has an image and description as well as a little box that indicates if the track serves food or not.

undefined

The above two components differ from each other a little in style (background color) and the tile for tracks has additional info. In this example there are only two models.  In a large app there will be many and creating separate components for each is counter-intuitive.

You can spare yourself from rewriting the similar code by creating adaptive components that are aware of the context they are being loaded in. Consider the search bar of the app.

undefined

It will be used on multiple pages of your app and will slightly differ in functionality and look. For example, it will be slightly bigger on the homepage. To handle this you can create a single component that will render according to props passed to it.

static propTypes = {
  open: PropTypes.bool.isRequired,
  setOpen: PropTypes.func.isRequired,
  goTo: PropTypes.func.isRequired,
};

Using this method you can toggle HTML classes in these components to control how they look as well.

Another good example of where adaptive components can be used is the pagination helper. Almost every page of the app has it and is more or less the same.

undefined
If your API follows a constant design pattern the only props that you would need to pass to the adaptive pagination component are URL and items per page.

Conclusion

Over the years the React eco-system has matured, so much so that there’s hardly ever a need to reinvent the wheel at any step of your development. Though very useful, this has resulted in more complexity in choosing what's right for your project.

Every project is different in terms of scale and functionality. There is no single approach or generalization that will work every time, therefore, it’s necessary to have a plan before actual coding begins.

While doing so it is very easy to recognize which tools are right for you and which ones would be an overkill. An app with 2-3 pages and minimal API calls would not require datastores as complex as discussed above. I would go as far to say that you DON'T NEED REDUX for small projects.

When we plan ahead and draw out the components that are going to be present in our app we can see that there is a lot of repetition across pages. A lot of effort can be saved by simply reusing code or writing smart components.

Finally, I’d like to say that data is the backbone of every software project, this holds true for React applications as well.  As the app grows, the amount of data and the actions associated with it can easily overwhelm programmers.  Identification of concerns like datastores, reducers actions, sagas, etc beforehand can prove to be a huge advantage and makes writing them much more enjoyable.

If there's any other libraries or methodologies that you think might prove to be helpful while creating large scale React applications, let us know down in the comments. Hope you enjoyed this article and thanks for reading.

Any questions? Let us know in the comments.

Make sure you receive the freshest Butter product updates.
    

Related Articles