How to Build a Clean Redux Architecture With Redux-Observable and Typescript
By: Samuel Rafini
Tags: Typescript, Redux Observable, Frontend, Redux, React

Photo by Brett Jordan on Unsplash

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

reusable fetch function and localstorage function services with an http folder for my requests

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

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>();

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'}}

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,

};

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;

}

}

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;

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,

});

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

);

Yep, you’ve guessed it! RootEpics! 😆

import { combineEpics } from 'redux-observable';

import { playerEpics } from './player/epics';



export default combineEpics(

playerEpics,

);

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;

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.