DEV Community

Cover image for How to Build a Clean Redux Architecture With Redux-Observable and Typescript
SamuelRafini
SamuelRafini

Posted on • Originally published at samuelrafini.com on

How to Build a Clean Redux Architecture With Redux-Observable and Typescript

How to Build a Clean Redux Architecture With Redux-Observable and Typescript

Easily create completely typesafe actions with less boilerplate and completely typesafe Reducers.

To be honest I used to hate Redux but my perspective changed quickly as soon as I had to work with large complicated codebases. Redux is amazing for that and once you have experience with such, you’ll appreciate it. In this article, I’m going to show you how I setup redux with redux-observables (also works with other middlewares like redux-saga ) and typesafe-actions to be clean and scalable.

Folder structure

The folder structure is pretty straightforward. Inside my src folder, I’ve added several folders:

  • components

  • pages

  • utils reusable fetch function and localstorage function

  • services with an http folder for my requests

  • redux well for redux πŸ˜†, notice I’m using the ducks architecture

Basically, in ducks architecture, you have a separate folder per domain or feature containing actions, epics/sagas, reducers, and selectors files.

On the official documentation of Redux they strongly recommend Structure Files as Feature Folders or Ducks

they strongly recommend Structure Files as Feature Folders or Ducks

In this example, I will create a simple application where you can search for NBA players and sort by name. I’m receiving data from www.balldontlie.io API.

Git Repo

πŸ“ test-app/
┣ πŸ“ public/
┣ πŸ“ src/
┃ ┣ πŸ“ components/
┃ ┣ πŸ“ pages/
┃ ┣ πŸ“ redux/
┃ ┃ ┣ πŸ“ player/
┃ ┃ ┃ ┣ πŸ“„ actions.ts
┃ ┃ ┃ ┣ πŸ“„ epics.ts
┃ ┃ ┃ ┣ πŸ“„ reducers.ts
┃ ┃ ┃ β”— πŸ“„ selectors.ts
┃ ┃ ┣ πŸ“„ rootAction.ts
┃ ┃ ┣ πŸ“„ rootEpic.ts
┃ ┃ ┣ πŸ“„ rootReducer.ts
┃ ┃ ┣ πŸ“„ store.ts
┃ ┃ β”— πŸ“„ utils.ts
┃ ┣ πŸ“ services/
┃ ┃ β”— πŸ“ http/
┃ ┃   β”— πŸ“„ nbaRequests.ts
┃ ┣ πŸ“ utils/
┃ ┃ ┣ πŸ“„ fetchApi.ts
┃ ┃ β”— πŸ“„ localstorage.ts
┃ ┣ App.tsx
┃ ┣ πŸ“„ index.tsx
┃ ┣ πŸ“„ interfaces.ts
┃ ┣ πŸ“„ react-app-env.d.ts
┣ πŸ“„ package.json
β”— πŸ“„ tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Actions Setup

We create a simple fetch player’s actions inside players. Players, in this case, is the domain/feature of this application.

import { createAction } from 'typesafe-actions';
import { FetchPlayersRequest, Player, PaginationMeta, ErrorResponse } from '../../interfaces';

export const getPlayers = createAction("GET_PLAYERS")<FetchPlayersRequest>();
export const getPlayersSuccess = createAction("GET_PLAYERS_SUCCESS")<{data: Player[]; meta: PaginationMeta}>();
export const getPlayersFailed = createAction("GET_PLAYERS_FAILED")<ErrorResponse>();
Enter fullscreen mode Exit fullscreen mode

Notice how createAction from Typesafe-actions helps us to reduce types verbosity. So basically getPlayersSuccess returns an action with an object of dataPlayer and meta as payload. for example

const getName = createAction("GET_FULL_NAME")<{firstName: string; lastName: string}>();

getName({firstname: "Samuel", lastName: "Rafini"}); // {type: "GET_FULL_NAME", payload: {firstName: 'Samuel', lastName: 'Rafini'}}
Enter fullscreen mode Exit fullscreen mode

In the root of the redux folder, we then add rootAction.ts file with the following, so we can join actions from other features/domains.

import * as playerActions from './player/actions';

export default {
    player: playerActions,
};
Enter fullscreen mode Exit fullscreen mode

Before going any further we can extend and add types of typesafe-actions like so

import { StateType, ActionType } from 'typesafe-actions';

/// <reference types="react-scripts" />

declare module 'typesafe-actions' {
  export type Store = StateType<typeof import('./redux/store').default>;

  export type RootState = StateType<typeof import('./redux/rootReducer').default>;

  export type RootAction = ActionType<typeof import('./redux/rootAction').default>;

  interface Types {
    RootAction: RootAction;
  }
}
Enter fullscreen mode Exit fullscreen mode

Reducers Setup

the Reducer is also straightforward. for our player state, we create a PlayerState interface and an initial state, then we use createRudecer from Typesafe-actions which helps us write our reducers without the switch case statement.

import { createReducer } from 'typesafe-actions';
import { Player, PaginationMeta, ErrorResponse } from '../../interfaces';

export interface PlayerState {
    players: Player[];
    pagination: PaginationMeta;
    loading: boolean;
    error?: ErrorResponse;
}

const initState: PlayerState = {
    players: [],
    pagination: {
      total_pages: 0,
      current_page: 1,
      next_page: 0,
      per_page: 100,
      total_count: 0,
    },
    loading: false,
    error: undefined,
}

const playerReducer = createReducer<PlayerState>(initState, {
    GET_PLAYERS: (state, _) => ({...state, loading: true}),
    GET_PLAYERS_SUCCESS: (state, action) => ({...state, loading: false, players: action.payload.data, pagination: action.payload.meta}),
    GET_PLAYERS_FAILED: (state, action) => ({...state, loading: false, error: action.payload}),
});

export default playerReducer;
Enter fullscreen mode Exit fullscreen mode

Just like the actions we have a rootReducer file where we combine all our reducers

import { combineReducers } from 'redux';
import playerReducer from './player/reducers';

export default combineReducers({
    player: playerReducer,
});
Enter fullscreen mode Exit fullscreen mode

Middleware Setup (Redux-Observables)

I will not go in details on Redux-observables but its worth noticing the isActionOf function from typeSafe-actions filter(isActionOf(getPlayers))

import { combineEpics, Epic } from 'redux-observable';
import { filter, mergeMap, debounceTime } from 'rxjs/operators';

import { isActionOf, RootAction, RootState } from 'typesafe-actions';
import { getPlayers, getPlayersSuccess, getPlayersFailed } from './actions';
import { fetchPlayers, fetchSeasonAverages } from '../../services/http/nbaRequests';
import { Player, SeasonAverages } from '../../interfaces';

export const getPlayersEpic: Epic<RootAction, RootAction, RootState> = (action$, state$) => {
    return action$.pipe(
        debounceTime(500),
        filter(isActionOf(getPlayers)),
        mergeMap(async ({payload}) => {
            const response = await fetchPlayers({search: payload.search, page: payload.page, perPage: payload.perPage});
            if (response.data) {
              const playersIds = response.data.map((player: Player) => player.id);
              const playersAvg = await fetchSeasonAverages(playersIds);

              const findAvg = (id: number) => playersAvg.data.find((item: SeasonAverages) => item.player_id === id);

              const players: Player[] = response.data.map((player: Player) => ({...player, seasonAverage: findAvg(player.id)}))
              return getPlayersSuccess({data: players, meta: response.meta})
            }
            return getPlayersFailed(response);
        }),
    )
};

export const playerEpics = combineEpics(
  getPlayersEpic
);
Enter fullscreen mode Exit fullscreen mode

Yep, you’ve guessed it! RootEpics! πŸ˜†

import { combineEpics } from 'redux-observable';
import { playerEpics } from './player/epics';

export default combineEpics(
playerEpics,
);

Enter fullscreen mode Exit fullscreen mode




Store Setup

Last but not least you can set up the store as following nothing new actually.

import { createStore, applyMiddleware } from 'redux';
import { RootAction, RootState } from 'typesafe-actions';
import { createEpicMiddleware } from 'redux-observable';

import rootReducer from './rootReducer';
import rootEpic from './rootEpic';
import { composeEnhancers } from './utils';
import { loadState, saveState } from '../utils/localstorage';

export const epicMiddleware = createEpicMiddleware<
RootAction,
RootAction,
RootState
>();

const middlewares = [epicMiddleware];

const enhancer = composeEnhancers(applyMiddleware(...middlewares));

const initialState = loadState();

const store = createStore(rootReducer, initialState, enhancer);

store.subscribe(() => {
saveState(store.getState());
});

epicMiddleware.run(rootEpic);

export default store;

Enter fullscreen mode Exit fullscreen mode




Alternative ways

If you still find setting up the store requires too much boilerplate or complicated you can use redux-toolkit which also is highly recommended in the official documentation of Redux.

It is intended to be the standard way to write Redux logic. I never used it myself yet, but will use it in my next project.

Top comments (0)