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.
π 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.
Top comments (0)