RETROSPECTIVE

December 3rd, 2021

Redux in a TypeScript React Application: Following the Ducks Pattern

Redux

React

TypeScript

JavaScript

Flux

s

This is part of a series of articles on SaintsXCTF Version 2.0. The first article in the series provides an overview of the application. You DO NOT need to read prior articles in the series to fully understand this article.

Redux is a state management pattern that is commonly used in complex React applications. Redux, based on the Flux design pattern, is helpful when application state is shared between many pages of a website and is updated frequently1. In my saintsxctf.com website, Redux stores and uses application state across all my webpages. SaintsXCTF is a good example of a website that can benefit from Redux; it needs login information, user preferences, team memberships, group memberships, and more to be shared amongst all pages.

In this article, I start by going over the basics of Redux. I'll also explain the “ducks pattern”, which is an approach to writing Redux code. Then, I'll walk through the Redux configuration and code in my SaintsXCTF application. All the code for my SaintsXCTF web application is available in a GitHub repository.

Redux is an open-source JavaScript library based on the Flux design pattern.

Flux

Flux is a web-development design pattern and architecture which is an alternative to the MVC (Model View Controller) pattern2,3. Flex maintains application state in one or many stores. Stores are updated with actions, which contain new state data and instructions for updating a store4. Actions are passed to stores via a dispatcher. Once actions reach the store, application state is updated accordingly, and any views that use the application state are re-rendered.

Flux creates a unidirectional flow of data, beginning with an event on the UI, such as a button click. UI events trigger actions, with travel through the dispatcher to a store. Finally, the UI updates according to the new state in the store. If you want to understand Flux in more detail, I recommend reading the official In-Depth Overview.

Redux

Redux is based on the Flux design pattern with a few key differences. Unlike Flux, Redux consists of a single store containing an immutable state object. Redux also does not have dispatchers. Instead, Redux has reducers. Reducers are functions which take an action and the existing state as arguments. During the execution of a reducer function, a new state object is created and returned5.

Similar to Flux, Redux follows a unidirectional data flow. Once again, actions are created when UI events occur. Actions pass data to the store, the state in the store is updated with the help of a reducer, and the UI is updated accordingly.

Let's describe the main components of Redux in more detail. These components include actions, action creators, reducers, and the store.

Redux Components

Actions

Actions are JavaScript objects which describe how to update the application state in the Redux store. Action objects have a type field, which is a string that identifies the action. Actions often have additional fields with instructions on how to update the state or data to add to the state. For example, an action to sign in a user and place their username in the application state may look like {type: "SIGN_IN", username: "andy"}.

Actions are dispatched to the Redux store. The Redux store invokes a reducer function corresponding to the action. This reducer function returns a new application state with changes made, which are determined by the action.

Action Creators

Action creators are functions that create Redux action objects. They simplify the creation of actions by encapsulating the logic for building an action of a specific type within a single function. For example, the action {type: "SIGN_IN", username: "andy"} can be built from an action creator signIn() that takes a username as an argument.

Sometimes, action creators perform more complex logic, such as performing asynchronous operations to external services and interfaces; for example, an action creator may invoke an API and create actions based on the response.

Reducers

Reducers are functions that create new state objects. Reducers take two arguments: an action object and an existing state object. Reducers create a copy of the existing state object and apply changes specified in the action. The return value of a reducer is a new state object, which replaces the existing state object in the Redux store.

Store

Redux applications contain a single store, which holds the application state. Application state is immutable, i.e. an application state object cannot be modified. To change the state in the store, a new state object replaces an existing state object. New state objects are created with the help of reducer functions. Reducer functions are called when actions are dispatched to the store.

When using Redux in a React application, the React Redux library is often used to simplify the configuration and usage of Redux. I utilize React Redux in my SaintsXCTF application.

The ducks pattern relates to the file layout of Redux code. In older applications, Redux code is often grouped into separate directories for actions, reducers, and action creators6. With the ducks pattern, actions, reducers, and action creators exist in the same files. However, code is logically grouped in separate files.

For example, my SaintsXCTF application has a modules directory containing all the Redux TypeScript files following the ducks pattern. Each file in this directory relates to a specific feature of the application. The groups.ts file, for example, contains actions, reducers, and action creators that relate to group pages on the website (user's in SaintsXCTF can be members of one or many groups).

The following code snippet is an abbreviated version of groups.ts:

// groups.ts // Actions const GET_GROUP_REQUEST = 'saints-xctf-web/groups/GET_GROUP_REQUEST'; ... // Action Types interface GetGroupRequestAction { type: typeof GET_GROUP_REQUEST; groupId: number; } ... // Reducers function getGroupRequestReducer(state: GroupState, action: GetGroupRequestAction): GroupState { const existingGroupState = state.group[action.groupId] ?? {}; return { ...state, group: { ...state.group, [action.groupId]: { ...existingGroupState, isFetching: true, lastUpdated: moment().unix(), serverError: null } } }; } ... export default function reducer(state = initialState, action: GroupActionTypes): GroupState { switch (action.type) { case GET_GROUP_REQUEST: return getGroupRequestReducer(state, action); ... default: return state; } } // Action Creators export function getGroupRequest(groupId: number): GetGroupRequestAction { return { type: GET_GROUP_REQUEST, groupId }; } ...

Redux is configured within the React application using the React Redux library. Both react-redux and @types/react-redux are specified as application dependencies in the package.json file. The configuration of Redux begins in the entrypoint of the React application, index.ts. A simplified version of the code is shown below.

import React from 'react'; import { render } from 'react-dom'; import configureStore from './redux/store'; import { Provider } from 'react-redux'; const store = configureStore(); const RoutedApp = (): JSX.Element => { return ( <Provider store={store}> <App /> </Provider> ); }; render(<RoutedApp />, document.getElementById('react-container')); export default RoutedApp;

I removed all the details unrelated to Redux from this code, with the complete version available on GitHub. The React Redux library supplies a <Provider /> component, which makes the Redux store available to all components in the application7. As you will see later on, the Redux store is accessible via React hooks.

<Provider /> is used at the top level of the application, right after bootstrapping React onto the DOM with the render() method call. <Provider /> takes a required prop store, which is the Redux store for the application. In my case, the Redux store is created with the line const store = configureStore(). configureStore() is a custom function, which is shown below.

import { createBrowserHistory } from 'history'; import { createStore, applyMiddleware, Store } from 'redux'; import { routerMiddleware } from 'connected-react-router'; import { createLogger } from 'redux-logger'; import thunkMiddleware from 'redux-thunk'; import reducer from './modules/reducers'; export const history = createBrowserHistory(); export default function configureStore(): Store { const loggerMiddleware = createLogger(); const middleware = [routerMiddleware(history), loggerMiddleware, thunkMiddleware]; const createStoreWithMiddleware = applyMiddleware(...middleware)(createStore); const store = createStoreWithMiddleware(reducer(history)); return store; }

The code above is a simplified version of a store.ts file in my application. configureStore() is a function that returns a Redux store of type Store. You will often see variants of the code above in Redux tutorials. Let's walk through this code line by line.

const loggerMiddleware = createLogger() creates a logging middleware for Redux using the redux-logger library. Redux middleware is a means for adding third party extensions to Redux. Third party libraries are invoked after actions are dispatched to the store, but before the store invokes a reducer function8. redux-logger provides middleware for logging actions to the console output of a web browser. This is very helpful for debugging.

loggerMiddleware is utilized in the line const middleware = [routerMiddleware(history), loggerMiddleware, thunkMiddleware]. middleware is a list of Redux middlewares used in my application. routerMiddleware is a React Router middleware and thunkMiddleware is a middleware for writing asynchronous code that interacts with the Redux store (for example, dispatching an action based on the result of an API request)9.

const createStoreWithMiddleware = applyMiddleware(...middleware)(createStore) is a complicated looking piece of code. applyMiddleware is a higher order function (a function that returns another function). It takes my list of Redux middlewares as an argument. The result of applyMiddleware is a function that takes createStore as an argument. The result is createStoreWithMiddleware, which itself is a function.

createStoreWithMiddleware() is used to create a Redux store with our list of middlewares applied. The line createStoreWithMiddleware(reducer(history)) accomplishes this task. reducer() is a function that creates all the Redux reducers for my application. It also takes a browser history object as an argument (history) for use with the React Router middleware.

The following code shows the reducer() function (named here as createRootReducer). It exists in a reducers.ts file in my repository.

import { combineReducers, Reducer } from 'redux'; import { connectRouter } from 'connected-react-router'; import auth from './auth'; import registration from './registration'; import logs from './logs'; import memberships from './memberships'; import notifications from './notifications'; import profile from './profile'; import rangeView from './rangeView'; import teams from './teams'; import groups from './groups'; import { History } from 'history'; const createRootReducer = (history: History): Reducer => combineReducers({ router: connectRouter(history), auth, registration, logs, memberships, notifications, profile, rangeView, teams, groups }); export default createRootReducer;

createRootReducer() is a function that creates a reducing function for the Redux store10. In other words, it creates the Redux reducer for my application. createRootReducer() returns the result of combineReducers(), which is a utility function built-in to Redux. combineReducers() takes an object of reducer functions as an argument. It merges these reducers and returns a single reducer function that can be used by the Redux store. With the help of combineReducers(), reducers can be split across multiple files, allowing engineers to follow the Ducks pattern. As you can see, I have separate reducer functions for different application features (auth, registration, logs, etc.). Each of these files is referred to as a Redux Ducks module11.

Each Redux Ducks module is a JavaScript or TypeScript file containing Redux actions, action creators, and a reducer. When using TypeScript, a module can also contain type definitions for actions.

For example, in my React/TypeScript application, one of my Redux Ducks modules is teams.ts. teams.ts contains Redux logic for a single feature of my website - teams that users can be members of. My Redux Ducks modules are logically split into five sections: actions, action types, reducers, action creators, and redux thunk functions.

In Redux, actions have a type property, which is a unique identifier for the action. The type property is a string; all the type strings used in a module are defined at the top of the module. For example, these are the type strings defined in my teams.ts module.

const GET_TEAM_REQUEST = 'saints-xctf-web/teams/GET_TEAM_REQUEST'; const GET_TEAM_SUCCESS = 'saints-xctf-web/teams/GET_TEAM_SUCCESS'; const GET_TEAM_FAILURE = 'saints-xctf-web/teams/GET_TEAM_FAILURE'; const GET_TEAM_GROUPS_REQUEST = 'saints-xctf-web/teams/GET_TEAM_GROUPS_REQUEST'; const GET_TEAM_GROUPS_SUCCESS = 'saints-xctf-web/teams/GET_TEAM_GROUPS_SUCCESS'; const GET_TEAM_GROUPS_FAILURE = 'saints-xctf-web/teams/GET_TEAM_GROUPS_FAILURE'; const SEARCH_TEAMS_REQUEST = 'saints-xctf-web/teams/SEARCH_TEAMS_REQUEST'; const SEARCH_TEAMS_SUCCESS = 'saints-xctf-web/teams/SEARCH_TEAMS_SUCCESS'; const SEARCH_TEAMS_FAILURE = 'saints-xctf-web/teams/SEARCH_TEAMS_FAILURE';

All actions follow the naming convention 'application-name/module-name/ACTION_NAME'. While Redux does not mandate this convention, I find it elegant and effective in removing the risk of naming conflicts between actions in different modules.

Action type strings are used by action creators and reducers. Action creators use action type strings when building an action object. Reducers use action type strings to determine which reducer function to invoke based on the action dispatched to the Redux store.

Action types are used by TypeScript to ensure that action objects are well-typed. In other words, an action type defines an interface for an action, containing all the properties in an action and their corresponding types. The following code shows all the action types defined in my teams.ts module.

interface GetTeamRequestAction { type: typeof GET_TEAM_REQUEST; teamName: string; } interface GetTeamSuccessAction { type: typeof GET_TEAM_SUCCESS; team: Team; teamName: string; } interface GetTeamFailureAction { type: typeof GET_TEAM_FAILURE; serverError: string; teamName: string; } interface GetTeamGroupsRequestAction { type: typeof GET_TEAM_GROUPS_REQUEST; teamName: string; } interface GetTeamGroupsSuccessAction { type: typeof GET_TEAM_GROUPS_SUCCESS; groups: Group[]; teamName: string; } interface GetTeamGroupsFailureAction { type: typeof GET_TEAM_GROUPS_FAILURE; serverError: string; teamName: string; } interface SearchTeamsRequestAction { type: typeof SEARCH_TEAMS_REQUEST; text: string; } interface SearchTeamsSuccessAction { type: typeof SEARCH_TEAMS_SUCCESS; teams: Team[]; text: string; } interface SearchTeamsFailureAction { type: typeof SEARCH_TEAMS_FAILURE; serverError: string; text: string; } type TeamActionTypes = | GetTeamRequestAction | GetTeamSuccessAction | GetTeamFailureAction | GetTeamGroupsRequestAction | GetTeamGroupsSuccessAction | GetTeamGroupsFailureAction | SearchTeamsRequestAction | SearchTeamsSuccessAction | SearchTeamsFailureAction;

Each action has a corresponding interface, containing a type property and zero to many additional properties. For example, type GetTeamSuccessAction has a type property along with team and teamName properties. All these types are unioned together into a single TeamActionTypes type.

Action types are used extensively when defining reducers and action creators.

In each module, there is a root reducer and one or many reducer functions. The root reducer decides which reducer function to invoke based on the type of an action dispatched to the Redux store. The following code shows the root reducer for my teams.ts module.

export default function reducer(state = initialState, action: TeamActionTypes): TeamState { switch (action.type) { case GET_TEAM_REQUEST: return getTeamRequestReducer(state, action); case GET_TEAM_SUCCESS: return getTeamSuccessReducer(state, action); case GET_TEAM_FAILURE: return getTeamFailureReducer(state, action); case GET_TEAM_GROUPS_REQUEST: return getTeamGroupsRequestReducer(state, action); case GET_TEAM_GROUPS_SUCCESS: return getTeamGroupsSuccessReducer(state, action); case GET_TEAM_GROUPS_FAILURE: return getTeamGroupsFailureReducer(state, action); case SEARCH_TEAMS_REQUEST: return getSearchTeamsRequestReducer(state, action); case SEARCH_TEAMS_SUCCESS: return getSearchTeamsSuccessReducer(state, action); case SEARCH_TEAMS_FAILURE: return getSearchTeamsFailureReducer(state, action); default: return state; } }

The root reducer is written as the reducer() function. reducer() is the main export of the Redux Ducks module. reducer() takes two arguments: state and action. state holds the existing state of the Redux store, and action holds an action object that was dispatched to the store. The type property of the action object determines which reducer function to invoke from the root reducer.

action.type is used in the switch statement to select a reducer function. For example, if an action {type: "GET_TEAM_REQUEST", teamName: "saintsxctf"} is dispatched to the Redux store and the reducer is invoked, getTeamRequestReducer(state, action) will be called. getTeamRequestReducer() has the following implementation:

function getTeamRequestReducer(state: TeamState, action: GetTeamRequestAction): TeamState { const existingTeamState = state.team[action.teamName] ?? {}; return { ...state, team: { ...state.team, [action.teamName]: { ...existingTeamState, isFetching: true, lastUpdated: moment().unix(), serverError: null } } }; }

This reducer function creates a new state object using the existing state and the JavaScript spread (...) notation. Based on the contents of the action object argument, a field team[action.teamName] is added to the application state.

If the existing state was:

{ team: {}, search: {} }

The new state after a {type: "GET_TEAM_REQUEST", teamName: "saintsxctf"} action is dispatched may look like:

{ team: { saintsxctf: { isFetching: true, lastUpdated: 1638565750, serverError: null } }, search: {} }

Every action type has a corresponding action creator, which is a function that creates an action object. The following code shows some of the action creators available in my teams.ts module.

export function getTeamRequest(teamName: string): GetTeamRequestAction { return { type: GET_TEAM_REQUEST, teamName }; } export function getTeamSuccess(team: Team, teamName: string): GetTeamSuccessAction { return { type: GET_TEAM_SUCCESS, team, teamName }; } export function getTeamFailure(serverError: string, teamName: string): GetTeamFailureAction { return { type: GET_TEAM_FAILURE, serverError, teamName }; }

Basically, action creators parameterize actions. Return values of action creators are often dispatched directly to the Redux store. Other times, they are dispatched as part of Redux Thunk functions.

Redux Thunk functions are created using the redux-thunk library. Redux thunk functions are action creators, except they return a function instead of an action12. These functions run asynchronous code and dispatch actions to the Redux store depending on certain conditions. The main use of redux-thunk in my application is to make API calls and dispatch actions depending on API responses.

For example, the following Redux Thunk function makes a call to my API, retrieving a team with a specific name. It exists in my teams.ts module.

export function getTeam(teamName: string): AppThunk<Promise<void>, TeamState> { return async function (dispatch: Dispatch): Promise<void> { dispatch(getTeamRequest(teamName)); try { const response = await api.get(`teams/${teamName}`); const { team } = response.data; dispatch(getTeamSuccess(team, teamName)); } catch (error) { const { response } = error as AxiosError; const serverError = response?.data?.error ?? 'An unexpected error occurred.'; if (response?.status !== 403) { dispatch(getTeamFailure(serverError, teamName)); } } }; }

getTeam() is a Redux Thunk function that returns an async function. This function takes a single argument called dispatch, which is a Redux function that dispatches actions to the store. dispatch is used three times. The first, dispatch(getTeamRequest(teamName)), dispatches an action indicating that an API request is about to be made. This is useful for implementing loading icons or loading screens. Inside the try… catch block, await api.get(`teams/${teamName}`) makes the actual API call. The second dispatch, dispatch(getTeamSuccess(team, teamName)), dispatches an action when the API call is successful. The team variable comes from the API response body. The third dispatch, dispatch(getTeamFailure(serverError, teamName)) dispatches an action when the API call returns an error.

If you look at the Redux Thunk functions in my teams.ts module or throughout my entire application, you will notice that most follow this same pattern.

When working with Redux in a React application, the react-redux library provides React hooks for dispatching actions to the Redux store and reading application state from the Redux store. These React hooks are useDispatch() and useSelector(), respectively.

Continuing with the example of teams in my application, let's look at the page which displays all the teams that a user is a member of.

The following code is for a TeamsBody component, which represents the page shown above. The code below is shortened for readability, with the full code available in a TeamsBody.tsx file in my repository.

import React, { useEffect, useState } from 'react'; import { GroupMeta, Memberships, RootState, TeamMembership, User, Users } from '../../../redux/types'; import { useDispatch, useSelector } from 'react-redux'; import { getUserMemberships } from '../../../redux/modules/profile'; import { getTeamGroups } from '../../../redux/modules/teams'; import { useNavigate } from 'react-router-dom'; interface Props { user: User; } const TeamsBody: React.FunctionComponent<Props> = ({ user }) => { const navigate = useNavigate(); const dispatch = useDispatch(); const userProfiles: Users = useSelector((state: RootState) => state.profile.users); const teams = useSelector((state: RootState) => state.teams.team); const [memberships, setMemberships] = useState<TeamMembership[]>(null); useEffect(() => { if (userProfiles && user?.username && !userProfiles[user.username]?.memberships) { dispatch(getUserMemberships(user.username)); } }, [dispatch, user.username, userProfiles]); useEffect(() => { if (userProfiles && user.username) { const membershipDetails: Memberships = userProfiles[user.username]?.memberships ?? {}; setMemberships(membershipDetails.teams?.filter((team) => team.status === 'accepted')); } }, [userProfiles, user.username]); useEffect(() => { if (memberships) { memberships.forEach((membership: TeamMembership) => { dispatch(getTeamGroups(membership.team_name)); }); } }, [memberships, dispatch]); return ( <div> <h3>Select a group.</h3> <div> {memberships?.map((membership) => ( <div key={membership.team_name} data-cypress="teamItem"> <h4>{membership.title}</h4> <div> {teams[membership.team_name]?.groups?.items?.map((group: GroupMeta) => ( <div data-cypress="groupItem" onClick={(): void => navigate(`/group/${group.id}`)}> <p>{group.group_title}</p> </div> ))} </div> </div> ))} </div> </div> ); }; export default TeamsBody;

First, notice that useDispatch() and useSelector() are imported from react-redux. useDistpatch() is invoked to get a dispatch function, with the line const dispatch = useDispatch(). On the next two lines, useSelector is used to retrieve data from the Redux store; specifically, user profiles and teams are retrieved, saved in userProfiles and teams variables, respectively.

dispatch is invoked within useEffect() React hooks. useEffect() is beyond the scope of this article; however, in general terms, this setup dispatches the results of action creators and Redux Thunk functions to the Redux store when the webpage loads or React props/state change. These Redux Thunk functions make the API calls that supply data for the front-end application. For example, dispatch(getUserMemberships(user.username)) gets all the team memberships from the API for the signed in user.

The return statement is the JSX for the React component, which is translated into HTML when displayed on a web browser.

Redux is a great way to store complex front-end application state. It integrates seamlessly with React and TypeScript. While I don't recommend its use for basic applications, any time a website requires shared state across pages or persisted sign in data, I recommend giving Redux a try. All the front-end code for my SaintsXCTF application, which uses Redux, is available on GitHub.

[1] "When should I use Redux?", https://redux.js.org/faq/general#when-should-i-use-redux

[2] Alex Banks & Eve Porcello, Learning React (Sebastopol, CA: O'Reilly, 2017), 174

[3] "Flux: In-Depth Overview", https://facebook.github.io/flux/docs/in-depth-overview

[4] Banks., 175

[5] Banks., 190

[6] "Structure Files as Feature Folders with Single-File Logic", https://redux.js.org/style-guide/style-guide#structure-files-as-feature-folders-with-single-file-logic

[7] "Usage with TypeScript", https://react-redux.js.org/using-react-redux/usage-with-typescript#provider

[8] "Redux Fundamentals, Part 4: Store - Middleware", https://redux.js.org/tutorials/fundamentals/part-4-store#middleware

[9] "Redux Thunk: Why Do I Need This?", https://github.com/reduxjs/redux-thunk#why-do-i-need-this

[10] "combineReducers(reducers)", https://redux.js.org/api/combinereducers

[11] "Ducks: Redux Reducer Bundles - Rules", https://github.com/erikras/ducks-modular-redux#rules

[12] "Redux Thunk: Motivation", https://github.com/reduxjs/redux-thunk#motivation