Dark Mode

Clean React Architecture with Redux, Immer, TypeScript and Redux Observable

Updated for using React Hooks.

If you have been writing frontend applications using React, there is a good chance you would’ve felt that the code becomes harder to maintain as the application grows. The solution to this problem that has worked well for me so far is using the right set of libraries and proper separation of code – The Clean React Architecture.

For illustration purposes, we will create a very simple todo listing application like below.

Clean React Architecture with Redux, Immer, TypeScript and Redux Observable

1. Create React App using TypeScript Template

We will be using TypeScript to introduce strict type checking in our React application. We can use create-react-app to create a fresh React application with TypeScript support by using the TypeScript template.

npx create-react-app clean-react-app --template typescript

Just by adding type definitions for all the objects and methods in our application, we can make our code more manageable in the long run and even catch hard to spot errors at compile-time.

It will be a boon to new developers joining the project and to yourself when you re-visit the project after a holiday!

2. Identifying & Defining Models

The first step is to define TypeScript interfaces for entities of our application. It is an ideal starting point because it will be the foundation of our application, and all other parts of our application will have a dependency on it.

For our sample todo application, we need to define the TodoItem interface and ApiStatus enum to represent a Todo item and the state of API calls, respectively.

export interface ITodoItem {
  id: number;
  description: string;
}

export enum ApiStatus {
  LOADING = 'loading',
  LOADED = 'loaded',
  FAILED = 'failed'
}

Convention: All interface names are to be prefixed with the letter ‘I’.

3. Redux for State Management

We will use Redux for all the state management because it makes our state more predictable, maintainable, and independently testable.

Let’s go ahead and install Redux, React-Redux, which is a React binding for Redux, Redux dev tools, and also the type definitions for React-Redux.

yarn add redux react-redux redux-devtools-extension
yarn add -D @types/react-redux

Strongly Typed Action Creators

We will give unique types for each of the actions using by TypeScript enums. Some people prefer to use constants for this, but I prefer using enums, it is very convenient and more of a TypeScript way in my opinion. Both works anyway, go ahead with your preferred way.

For our app, the following are the actions that are required:

  • LoadTodo – triggers an API call to load todos from the server.
  • LoadingTodos – will denote that API call is in progress.
  • LoadedTodos – denotes that API call has completed successfully.
  • LoadingTodosFailed – denotes that API call has failed.
  • AddTodo – triggers an API call to save a new todo.
  • AddingTodos – will denote that API call is in progress.
  • AddedTodo – denotes that API call has completed successfully.
  • AddingTodosFailed – denotes that API call has failed.
export enum TodosActionTypes {
  LOAD_TODOS = 'todos/load',
  LOADING_TODOS = 'todos/loading',
  LOADED_TODOS = 'todos/loaded',
  LOADING_TODOS_FAILED = 'todos/loading_failed',

  ADD_TODO = 'todos/add',
  ADDING_TODO = 'todos/adding',
  ADDED_TODOS = 'todos/added',
  ADDING_TODOS_FAILED = 'todos/adding_failed'
}

We will define the shape of the actions using interfaces.

export interface ILoadTodosAction {
  type: TodosActionTypes.LOAD_TODOS;
}

export interface ILoadingTodosAction {
  type: TodosActionTypes.LOADING_TODOS;
}

Convention: Name all the action interfaces with ‘Action’ as a suffix.

We will also create a type TodoAction that will group all the todo actions together using TypeScript unions. It will be helpful when we implement the reducers.

Finally, we will write our strongly-typed action creator functions using the enums and interfaces that we just defined.

import { ITodoItem } from "../models";

export function loadTodos(): ILoadTodosAction {
  return {
    type: TodosActionTypes.LOAD_TODOS
  }
}

export function loadingTodos(): ILoadingTodosAction {
  return {
    type: TodosActionTypes.LOADING_TODOS
  }
}

export function loadedTodos(todos: ITodoItem[]): ILoadedTodosAction {
  return {
    type: TodosActionTypes.LOADED_TODOS,
    payload: {
      todos
    }
  }
}

export function loadingTodosFailed(): ILoadingTodosFailedAction {
  return {
    type: TodosActionTypes.LOADING_TODOS_FAILED
  }
}

export function addTodo(description: string): IAddTodoAction {
  return {
    type: TodosActionTypes.ADD_TODO,
    payload: {
      description
    }
  }
}

export function addingTodo(): IAddingTodoAction {
  return {
    type: TodosActionTypes.ADDING_TODO
  }
}

export function addedTodo(todo: ITodoItem): IAddedTodoAction {
  return {
    type: TodosActionTypes.ADDED_TODOS,
    payload: {
      todo
    }
  }
}

export function addingTodoFailed(): IAddingTodoFailedAction {
  return {
    type: TodosActionTypes.ADDING_TODOS_FAILED
  }
}

Reducers with Immer

We will be using Immer – a library to work with immutability with ease, and it has excellent support for TypeScript.

Immer exposes a function called produce that takes the current state as input and gives a draft object on which all the state updates are performed. It will record all the updates, and finally, once we return the draft object, it will replay all the recorded updates on the state without mutating it. Immer makes this possible through JavaScript Proxy objects. That being said, we can jump to the implementation part.

We will define an interface ITodoState that will represent the “slice” of the state dedicated to keeping all the todo specific data. We can then use the interface to define the initial value.

export const initialTodoState: ITodoState = {
  loadingStatus: ApiStatus.LOADING,
  addingStatus: ApiStatus.LOADED,
  todos: []
}

export interface ITodoState {
  loadingStatus: ApiStatus;
  addingStatus: ApiStatus;
  todos: ITodoItem[];
}

Now we can implement the “slice reducer” function that will be responsible for managing all the updates to todos “slice “of the state. Our todo reducer will always act on the todo slice of the state, and it will respond to todo actions. Hence we can say it is a function of ITodoState and TodosAction. Here is the full implementation of our todos reducer.

export default function todosReducer(state: ITodoState = initialTodoState, action: TodosAction) {
  return produce(state, draft => {
    switch (action.type) {
      case TodosActionTypes.LOAD_TODOS:
      case TodosActionTypes.LOADING_TODOS:
        draft.loadingStatus = ApiStatus.LOADING;
        break;

      case TodosActionTypes.LOADING_TODOS_FAILED:
        draft.loadingStatus = ApiStatus.FAILED;
        break;

      case TodosActionTypes.LOADED_TODOS:
        draft.loadingStatus = ApiStatus.LOADED;
        draft.todos = action.payload.todos;
        break;

      case TodosActionTypes.ADD_TODO:
      case TodosActionTypes.ADDING_TODO:
        draft.addingStatus = ApiStatus.LOADING;
        break;

      case TodosActionTypes.ADDING_TODOS_FAILED:
        draft.addingStatus = ApiStatus.FAILED;
        break;

      case TodosActionTypes.ADDED_TODOS:
        draft.todos.push(action.payload.todo);
        break;
    }
  });
}

Just like how we use to combine all the “slice reducer” functions to create our root reducer, we will combine interfaces and initial values of all the “slices” of the state to define the interface and initial values of the whole state.

import { combineReducers } from 'redux';
import todosReducer, { ITodoState, initialTodoState } from './todosReducer';

export interface IState {
  todos: ITodoState;
}

export const initialState: IState = {
  todos: initialTodoState
};

export default combineReducers({
  todos: todosReducer
});

Handling Async Actions with Redux Observables

In my opinion, using Redux Thunk to work with async actions makes our action creators long, dirty, and complex, as the application grows in size, it quickly becomes a problem. Redux observable is the solution to this.

As per the official Redux Observable website:

RxJS 6-based middleware for Redux. Compose and cancel async actions to create side effects and more.

Redux observable allows us to remove all the complexities from the action creators. Alternatively, we will write something called Epics to handle all the async actions.

An Epic is a function that takes a stream of actions and returns a stream of actions. Redux observable requires an understanding of RxJS, and it is out of the scope of this article. You can learn about RxJS here.

import { combineEpics, Epic } from "redux-observable";
import { switchMap, map, startWith, catchError, filter, mergeMap } from "rxjs/operators";
import axios from "axios";
import {
  TodosAction,
  TodosActionTypes,
  loadedTodos,
  loadingTodos,
  loadingTodosFailed,
  addedTodo,
  addingTodo,
  addingTodoFailed
} from "../actions/todosActions";
import { IState } from "../reducers";
import { from, of } from "rxjs";
import { isOfType } from "typesafe-actions";

const loadTodosEpic: Epic<TodosAction, TodosAction, IState> = (
  action$,
  state$
) =>
  action$.pipe(
    filter(isOfType(TodosActionTypes.LOAD_TODOS)),
    switchMap(action =>
      from(axios.get("http://localhost:5000/todos")).pipe(
        map(response => loadedTodos(response.data.data)),
        startWith(loadingTodos()),
        catchError(() => of(loadingTodosFailed()))
      )
    )
  );

const addTodoEpic: Epic<TodosAction, TodosAction, IState> = (
  action$,
  state$
) => action$.pipe(
  filter(isOfType(TodosActionTypes.ADD_TODO)),
  mergeMap(action =>
    from(axios.post("http://localhost:5000/todos", action.payload)).pipe(
      map(response => addedTodo(response.data.data)),
      startWith(addingTodo()),
      catchError(() => of(addingTodoFailed()))
    )  
  )
)

export default combineEpics(loadTodosEpic, addTodoEpic);

Similar to reducers, we can split epics into multiple slice epics and finally combine them all using combineEpics method to create our root epic.

import { combineEpics, createEpicMiddleware } from 'redux-observable';
import todoEpics from './todoEpics';

export const rootEpic = combineEpics(todoEpics);

export default createEpicMiddleware();

Creating Redux Store

All the pieces required to create a Redux store are ready, we can put them all together along with Redux dev tools extension and create the store.

import { applyMiddleware, createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import epicMiddleware, { rootEpic } from './epics';

import rootReducer, { initialState } from './reducers';

const composeEnhancer = composeWithDevTools({
  name: 'React Clean Architecture'
});

const store = createStore(
  rootReducer,
  initialState,
  composeEnhancer(applyMiddleware(epicMiddleware))
);

epicMiddleware.run(rootEpic);

export default store;

We now have a fully typed Redux store!

4. React as View Layer

React should be just a view layer of our application. If we start writing logic in React components, it will become a mess very quickly. By moving all the logic to reducers and epics, we can keep our React components clean.

Creating Presentational Components

To keep it simple, we will put everything in a single component that will be responsible for loading todos from the server and list them. It will also have an option to create new todos.

Our component will need the loadTodos and addTodo action creators to load todos and create new todos, respectively. It also needs the loaded todos list, and the status of API calls to display the todos list and progress bar while it is loading.

Using React Hooks

We will directly import the action creators into our components. We will get the dispatch method using useDispatch hook, and use it to dispatch the actions. To get data from the redux store, we can use useSelector hook.

import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import { makeStyles, Theme } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Divider from '@material-ui/core/Divider';
import TextField from '@material-ui/core/TextField';
import { ITodoItem, ApiStatus } from '../models';
import { loadTodos } from '../actions/todosActions';
import Paper from '@material-ui/core/Paper';
import { CircularProgress, Typography } from '@material-ui/core';

const useStyles = makeStyles((theme: Theme) => ({
  wrap: {
    display: 'flex',
    justifyContent: 'center'
  },
  content: {
    width: 500
  },
  addButton: {
    marginTop: theme.spacing.unit
  },
  divider: {
    marginTop: theme.spacing.unit * 2,
    marginBottom: theme.spacing.unit * 2
  }
}));

const App: React.FC = (props) => {
  const classes = useStyles();
  const [desc, setDesc] = useState('');
  const todos = useSelector(state => state.todos.todos);
  const loadingStatus = useSelector(state => state.todos.loadingStatus);
  const classes = useStyles();
  const dispatch = useDispatch();
  
  useEffect(() => {
    dispatch(loadTodos());
  }, []);
  
  const addNewTodo = () => {
    dispatch(addTodo(desc));
  }
  
  const onDescChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setDesc(e.target.value);
  }
    
  return (
    <div className={classes.wrap}>
      <div className={classes.content}>

        <div>
          <TextField multiline placeholder="Enter todo message" rows="5" variant="outlined" onChange={onDescChange} value={desc} fullWidth />
          <Button className={classes.addButton} color="primary" variant="contained" onClick={addNewTodo} fullWidth>Add Todo</Button>
        </div>

        <Divider className={classes.divider} />

        <div>
          {loadingStatus === ApiStatus.LOADING && <CircularProgress />}

          {loadingStatus === ApiStatus.FAILED && <Typography color="error">Failed to load todos</Typography>}

          {loadingStatus === ApiStatus.LOADED && todos.map(todo => (
            <Paper key={todo.id}>
              {todo.description}
            </Paper>
          ))}
        </div>

      </div>
    </div>
  );
}

export default App;

Note: React v16.8+ and React Redux version v7.1.0+ is required for using hooks.

Using Class-Based Components

We will define all the action creators required by components using a separate interface. I usually name this interface such that it ends with ‘DispatchProps’. E.g., IAppDispatchProps

Similarly, We will define all the data required from the Redux store using a separate interface. I usually name this interface such that it ends with ‘StateProps’. E.g., IAppStateProps

Finally, we will create a union AppProps of IAppDispatchProps and IAppStateProps.

import React from 'react';

import { createStyles, Theme, withStyles, WithStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Divider from '@material-ui/core/Divider';
import TextField from '@material-ui/core/TextField';
import { ITodoItem, ApiStatus } from '../models';
import Paper from '@material-ui/core/Paper';
import { CircularProgress, Typography } from '@material-ui/core';

const styles = (theme: Theme) => createStyles({
  wrap: {
    display: 'flex',
    justifyContent: 'center'
  },
  content: {
    width: 500
  },
  addButton: {
    marginTop: theme.spacing.unit
  },
  divider: {
    marginTop: theme.spacing.unit * 2,
    marginBottom: theme.spacing.unit * 2
  }
});

class App extends React.Component<AppProps> {
  constructor(props) {
    super(props);
    
    this.state = {
      desc: '' 
    }
  }
  
  componentDidMount() {
    // Load todos on mount
    this.props.loadTodos();
  }

  addTodo = () => {
    this.props.addTodo(this.state.desc); 
  }

  onDescChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    this.setState({
      desc: e.target.value
    });
  }

  render() {
    const { classes, todos, loadingStatus } = this.props;
    
    return (
      <div className={classes.wrap}>
        <div className={classes.content}>

          <div>
            <TextField multiline placeholder="Enter todo message" rows="5" variant="outlined" onChange={this.onDescChange} value={this.state.desc} fullWidth />
            <Button className={classes.addButton} color="primary" variant="contained" onClick={this.addTodo} fullWidth>Add Todo</Button>
          </div>
          
          <Divider className={classes.divider} />

          <div>
            {loadingStatus === ApiStatus.LOADING && <CircularProgress />}

            {loadingStatus === ApiStatus.FAILED && <Typography color="error">Failed to load todos</Typography>}

            {loadingStatus === ApiStatus.LOADED && todos.map(todo => (
              <Paper key={todo.id}>
                {todo.description}
              </Paper>
            ))}
          </div>
    
        </div>
      </div>
    );
  }
}

export default withStyles(styles)(App);

// Define props coming from redux store
export interface IAppStateProps {
  loadingStatus: ApiStatus;
  todos: ITodoItem[];
}

// Define props that are action creators
export interface IAppDispatchProps {
  loadTodos: () => void;
  addTodo: (todo: ITodoItem) => void;
}

type AppProps = IAppStateProps & IAppDispatchProps & WithStyles<typeof styles>

To hook up the component with the Redux store, we will write a container, which simply imports IAppStateProps and uses it as the return type for mapStateToProps method, and imports IAppDispatchProps and use it as the type for mapDispatchToProps object.

import App, { IAppStateProps, IAppDispatchProps } from '../components/App';
import { IState } from '../reducers';
import { addTodo, loadTodos } from '../actions/todosActions';
import { connect } from 'react-redux';

function mapStateToDispatch(state: IState): IAppStateProps {
  return {
    todos: state.todos.todos,
    loadingStatus: state.todos.loadingStatus
  }
}

const mapDispatchToProps: IAppDispatchProps = {
  addTodo,
  loadTodos
}

export default connect(mapStateToDispatch, mapDispatchToProps)(App);

Don’t forget to add Provider at the root level for our application.

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

import store from './store';
import AppContainer from './containers/AppContainer';

ReactDOM.render(
  <Provider store={store}>
    <AppContainer />
  </Provider>,
  document.getElementById("app")
);

5. Linting and Formatting with Git Hooks

We will use TSLint and Prettier to lint and auto format our code. It helps us to keep the codebase style consistent. This is very helpful when there are multiple people working on the same application.

yarn add -D tslint tslint-config-standard tslint-config-prettier tslint-react prettier

We can provide the linting and formatting rules through tslint.json and .prettierrc, and these files should be placed at the root level of our application. Go ahead and configure it based on your coding style.

Here is my configuration:

{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "none",
  "tabWidth": 2,
  "useTabs": false,
  "bracketSpacing": true
}
{
  "defaultSeverity": "error",
  "extends": [
    "tslint:recommended",
    "tslint-config-standard",
    "tslint-react",
    "tslint-config-prettier"
  ],
  "jsRules": {},
  "rules": {
    "ordered-imports": true,
    "object-literal-sort-keys": false,
    "member-ordering": false,
    "jsx-no-lambda": false,
    "jsx-boolean-value": false,
    "member-access": false,
    "max-line-length": [true, 150],
    "no-var-requires": false
  },
  "rulesDirectory": []
}

Once you have configured, we will start using them in 3 different ways – through the IDE plugins or using Git hooks or manually running commands from the command line. I prefer to do it through IDE and Git hooks.

Using Git Hooks

For using Git hooks, we will be using Husky, and to run the linters on the staged files, we will use Lint Staged.

yarn add -D husky lint-staged

Add the following configurations to package.json. It basically says Husky to run lint-staged on Git commit. It then says lint-staged to run Prettier and TSlint on .ts, .tsx and .json files.

"husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{json}": [
      "prettier --write"
    ],
    "*.{ts,tsx}": [
      "prettier --write",
      "tslint --fix",
      "git add"
    ]
  }

Consequently, when you try to commit, all the changed files will be linted and formatted first as per the rules provided in the configuration. If it finds any issues that cannot be auto-fixed, then it won’t allow you to commit.

Using IDE

If you are using VSCode, you can install TSLint and Prettier plugins. The plugins will read the .prettierrc and tslint.json files and start linting and formatting as you write code. You might have to enable the Format on Save option in your VSCode settings.

You can find the full code here.

Let me know in the comments section if you have any questions/suggestions.

See responses (5)