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
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 usingcreate-react-app
, variables havingREACT_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}¶m2=${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
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.
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!
Top comments (0)