loading...
Cover image for Structuring React application for scale (Part I)

Structuring React application for scale (Part I)

anishkargaonkar profile image Anish Kargaonkar Updated on ・8 min read

One of the important aspects of writing maintainable code is setting up code properly. If code organization is not done properly, it can very much lead to bugs and affect development efficiency.

Why should we consider organizing code?

It can be perceived very differently across developers coming from different stacks and languages and there is no definitive way, but let's try to define why it can be good

  • Readability
  • Predictability
  • Consistency
  • Easier to debug
  • Easier to onboard new developers

In this article, I would like to share one way of organizing a react project which has worked for medium/large-scale applications. The way we are going to structure this is that we'll divide the application into smaller chunks(features), and each chunk will further be divided into

  • data: deals with managing state of the application
  • UI: deals with representing the state of data

This will help us maintain the whole application at an atomic level easily.

In this 2 part series, we'll define the structure from scratch. You'll also need some basic familiarity with the following:

  • React basics
  • React hooks
  • Redux for state management
  • Redux-toolkit for managing Redux
  • Redux-saga for handling side-effects (for e.g. API call)

Though this pattern works for small-scale projects it might be overkill but hey, everything starts small, right? The structure defined in this article will form the base of the app which we are going to create in the next article of this series.

Initialize project

Let's start by initializing the react project (in typescript) using create-react-app by running the following command in terminal

npx create-react-app my-app --template typescript

cra

After initializing, we'll end up with the above structure. All the business logic will go in /src folder.

Setting up Redux

For state management, we'll be using redux and redux-saga. We'll also be using RTK @reduxjs/toolkit (redux toolkit) which is an officially recommended approach for writing Redux logic. To allow redux-saga to listen for dispatched redux action we'll need to inject sagas while creating the reducer, for that redux-injectors will be used.

NOTE: We can also use other state management options like RxJS, Context API, etc.

yarn add @reduxjs/toolkit react-redux redux-saga @types/react-redux redux-injectors

Let's configure the Redux store by creating /src/reducer.ts, /src/saga.ts, and /src/store.ts

// /src/reducer.ts
import { combineReducers } from "@reduxjs/toolkit";

const reducers = {
  // ...reducers 
};

function createRootReducer() {
    const rootReducer = combineReducers({
      ...reducers
    });

    return rootReducer;
};

export { createRootReducer };
// /src/saga.ts
import { all, fork } from "redux-saga/effects";

function* rootSaga() {
    yield all([
        // fork(saga1), fork(saga2)
    ]);
};

export { rootSaga };
// /src/store.ts
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import { createInjectorsEnhancer } from 'redux-injectors';
import { createRootReducer } from './reducer';
import { rootSaga } from './saga';

export type ApplicationState = {
  // will hold state for each chunk/feature 
};

function configureAppStore(initialState: ApplicationState) {
  const reduxSagaMonitorOptions = {};
  const sagaMiddleware = createSagaMiddleware(reduxSagaMonitorOptions);

  const { run: runSaga } = sagaMiddleware;

  // sagaMiddleware: Makes redux saga works
  const middlewares = [sagaMiddleware];

  const enhancers = [
    createInjectorsEnhancer({
      createReducer: createRootReducer,
      runSaga
    })
  ];

  const store = configureStore({
    reducer: createRootReducer(),
    middleware: [...getDefaultMiddleware(), ...middlewares],
    preloadedState: initialState,
    devTools: process.env.NODE_ENV !== 'production',
    enhancers
  });

  sagaMiddleware.run(rootSaga);
  return store;
}

export { configureAppStore };

Now let's add redux store to the app using component in /src/App.tsx

// /src/App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';
import { Provider } from 'react-redux';
import store from './store';

function App() {
  return (
    <Provider store={store}>
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
      </div>
    </Provider>
  );
}

export default App;

Save and run the app using npm start to check if everything's running fine. To check if redux was properly integrated, you can open Redux DevTools in the browser.

Setting up the base

Before starting, let's define some basic analogy for how we are going to structure our project

  • config: application related configuration such as API endpoint, enums(constants), etc
  • components: custom components which are used in multiple places
  • containers: comprises of features or modules where components are connected to the Redux store
  • navigator: routing related logic goes here
  • services: modules that connect with the outside world such as all the APIs, Analytics, etc
  • utils: helper methods like API helpers, date helpers, etc

Let's clean up src/App.tsx and remove all the boilerplate code.

// src/App.tsx
import React from 'react';
import { Provider } from 'react-redux';
import { ApplicationState, configureAppStore } from './store';

const initialState: ApplicationState = {
  // ... initial state of each chunk/feature
};

const store = configureAppStore(initialState);

function App() {
  return (
    <Provider store={store}>
      <div>Hello world</div>
    </Provider>
  );
}

export default App;

Setting up router

For handling the routing logic of the application, we'll add react-router-dom to the project and create a component called Navigator in /src/navigator/

yarn add react-router-dom 
yarn add --dev @types/react-router-dom
// src/navigator/Navigator.tsx
import React, { FC } from "react";
import { Switch, Route, BrowserRouter as Router } from "react-router-dom";

type Props = {};

const Navigator: FC<Props> = () => {
  return (
    <Router>
      <Switch>
        <Route
            path="/"
            render={() => <div>Hello world</div>} />
      </Switch>
    </Router>
  );
};

export { Navigator };

and import component in /src/App.tsx

// /src/App.tsx
import React from "react";
import { Provider } from "react-redux";
import { ApplicationState, configureAppStore } from "./store";
import { Navigator } from "./navigator/Navigator";

const initialState: ApplicationState = {
  // ... initial state of each chunk/feature
};

const store = configureAppStore(initialState);

function App() {
  return (
    <Provider store={store}>
      <Navigator />
    </Provider>
  );
}

export default App;

hit save and you should be able to see Hello world text.

Setting up config

This folder will contain all the configuration related to the application. For the basic setup, we are going to add the following files

  • /.env: It contains all the environment variables for the application such as API endpoint. If a folder is scaffolded using create-react-app, variables having REACT_APP as a prefix will be automatically read by the webpack configuration, for more info you can check the official guide. If you have a custom webpack config you can pass these env variables from CLI or you can use packages like cross-env.
// .env 
// NOTE: This file is added at the root of the project
REACT_APP_PRODUCTION_API_ENDPOINT = "production_url"
REACT_APP_DEVELOPMENT_API_ENDPOINT = "development_url"
  • src/config/app.ts: It contains all the access keys and endpoints which are required by the application. All these configurations will be read from the environment variables defined above. For now, let's keep it simple, we'll have two environments namely, production and development.
// src/config/app.ts
type Config = {
  isProd: boolean;
  production: {
    api_endpoint: string;
  };
  development: {
    api_endpoint: string;
  };
};

const config: Config = {
  isProd: process.env.NODE_ENV === "production",
  production: {
    api_endpoint: process.env.REACT_APP_PRODUCTION_API_ENDPOINT || "",
  },
  development: {
    api_endpoint: process.env.REACT_APP_DEVELOPMENT_API_ENDPOINT || "",
  },
};

export default config;
  • src/config/enums.ts: It contains any global level enums(constants). For now, let's declare it.
// src/config/enums.ts
enum enums { 
    // GLOBAL_ENV = 'GLOBAL_ENV'
}

export default enums;
  • src/config/request.ts: It contains the default request config which we'll use later while making API calls. Here we can set some app-level API request configuration like timeout, maxContentLength, responseType, etc.
// src/config/request.ts
type RequestConfig = {
  url: string,
  method: "get" | "GET" | "delete" | "DELETE" | "head" | "HEAD" | "options" | "OPTIONS" | "post" | "POST" | "put" | "PUT" | "patch" | "PATCH" | undefined,
  baseURL: string,
  transformRequest: any[],
  transformResponse: any[],
  headers: any,
  params: any,
  timeout: number,
  withCredentials: boolean,
  responseType: "json" | "arraybuffer" | "blob" | "document" | "text" | "stream" | undefined,
  maxContentLength: number,
  validateStatus: (status: number) => boolean,
  maxRedirects: number,
}

const requestConfig: RequestConfig = {
  url: '',
  method: 'get', // default
  baseURL: '',
  transformRequest: [
    function transformRequest(data: any) {
      // Do whatever you want to transform the data
      return data;
    }
  ],
  transformResponse: [
    function transformResponse(data: any) {
      // Do whatever you want to transform the data
      return data;
    }
  ],
  headers: {},
  params: {},
  timeout: 330000,
  withCredentials: false, // default
  responseType: 'json', // default
  maxContentLength: 50000,
  validateStatus(status) {
    return status >= 200 && status < 300; // default
  },
  maxRedirects: 5, // default
};

export default requestConfig;

Current folder structure with the addition of following files:

  • /src/config/app.ts
  • /src/config/enums.ts
  • /src/config/requests.ts
  • /.env

Setting up API service

In this section, we are going to set up some helper methods for making API calls. For this, we are going to use Axios and write a wrapper for common local storage and API methods GET POST PUT PATCH DELETE. The following wrapper with some minors tweaks will even work with fetch API or XMLHTTPRequest which is readily available without any external library. This bit can be skipped, but a little bit of abstraction can provide better consistency and, clean and readable code.

Let's first add the Axios package to the project.

yarn add axios

Now we will create a file called api-helper.ts in /src/utils and add the following content to the file.

// /src/utils/api-helper.ts
import axios from "axios";
import requestConfig from "../config/request";

export type CustomError = {
  code?: number
  message: string
};

export const getCustomError = (err: any) => {
  let error: CustomError = {
    message:  "An unknown error occured" 
  };

  if (err.response
    && err.response.data
    && err.response.data.error
    && err.response.data.message) {
    error.code = err.response.data.error;
    error.message = err.response.data.message;
  } else if (!err.response && err.message) {
    error.message = err.message;
  }

  return error;
};

export const getFromLocalStorage = async (key: string) => {
  try {
    const serializedState = await localStorage.getItem(key);
    if (serializedState === null) {
      return undefined;
    }
    return JSON.parse(serializedState);
  } catch (err) {
    return undefined;
  }
};

export const saveToLocalStorage = async (key: string, value: any) => {
  try {
    const serializedState = JSON.stringify(value);
    await localStorage.setItem(key, serializedState);
  } catch (err) {
    // Ignoring write error as of now
  }
};

export const clearFromLocalStorage = async (key: string) => {
  try {
    await localStorage.removeItem(key);
    return true;
  } catch (err) {
    return false;
  }
};

async function getRequestConfig(apiConfig?: any) {
  let config = Object.assign({}, requestConfig);
  const session = await getFromLocalStorage("user");
  if (apiConfig) {
    config = Object.assign({}, requestConfig, apiConfig);
  }
  if (session) {
    config.headers["Authorization"] = `${JSON.parse(session).token}`;
  }
  return config;
}

export const get = async (url: string, params?: string, apiConfig?: any) => {
  const config = await getRequestConfig(apiConfig);
  config.params = params;
  const request = axios.get(url, config);
  return request;
};

export const post = async (url: string, data: any, apiConfig?: any) => {
  const config = await getRequestConfig(apiConfig);
  let postData = {};
  if (
    apiConfig &&
    apiConfig.headers &&
    apiConfig.headers["Content-Type"] &&
    apiConfig.headers["Content-Type"] !== "application/json"
  ) {
    postData = data;
    axios.defaults.headers.post["Content-Type"] =
      apiConfig.headers["Content-Type"];
  } else {
    postData = JSON.stringify(data);
    axios.defaults.headers.post["Content-Type"] = "application/json";
  }
  const request = axios.post(url, postData, config);
  return request;
};

export const put = async (url: string, data: any) => {
  const config = await getRequestConfig();
  config.headers["Content-Type"] = "application/json";
  const request = axios.put(url, JSON.stringify(data), config);
  return request;
};

export const patch = async (url: string, data: any) => {
  const config = await getRequestConfig();
  config.headers["Content-Type"] = "application/json";
  const request = axios.patch(url, JSON.stringify(data), config);
  return request;
};

export const deleteResource = async (url: string) => {
  const config = await getRequestConfig();
  const request = axios.delete(url, config);
  return request;
};

getCustomError process error into custom type CustomError and getRequestConfig takes care of adding authorization to API request if a user is authorized. This utility API helper can be modified according to the logic used by the back-end.

Let's go ahead and setup /src/services/Api.ts where we'll declare all our API calls. Anything which requires interaction with the outside world will come under /src/services, such as API calls, analytics, etc.

// /src/services/Api.ts
import config from "../config/app";
import * as API from "../utils/api-helper";

const { isProd } = config;

const API_ENDPOINT = isProd
  ? config.production.api_endpoint
  : config.development.api_endpoint;

// example GET API request
/** 
    export const getAPIExample = (params: APIRequestParams) => {
        const { param1, param2 } = params;
        const url = `${API_ENDPOINT}/get_request?param1=${param1}&param2=${param2}`;

        return API.get(url);
    }
*/

The current folder structure with the following change will look like this:

  • /src/utils/api-helper.ts
  • /src/services/Api.ts

Untitled 2

Next steps

Folks! this is pretty much it for this part, though one major section where we define all the business logic of the application .i.e. containers & components is left which we'll cover in the next part by creating a small Reddit client to fetch results for a particular topic.

I am also giving a link to this GitHub repository, please feel free to use it for your reference and if you like it please promote this repo to maximize its visibility.

GitHub logo anishkargaonkar / react-reddit-client

Reddit client for showing top results for given keywords

Thank you so much for reading this article, hope it was an interesting read! I would love to hear out your thoughts. See you in the next part. Adios!

Discussion

pic
Editor guide