Redux Toolkit as a Tool for Effective Redux Development

image

Currently, the lion's share of web applications based on the React framework is being developed using the Redux library. This library is the most popular implementation of FLUX architecture and, despite a number of obvious advantages, it has very significant disadvantages, such as:

  • the complexity and “verbosity” of the recommended patterns for writing and organizing code, which entails a large number of boilerplate;
  • lack of built-in controls for asynchronous behavior and side effects, which leads to the need to choose the right tool from a variety of add-ons written by third-party developers.

To address these shortcomings, Redux developers introduced the Redux Toolkit library. This tool is a set of practical solutions and methods designed to simplify application development using Redux. The developers of this library aimed to simplify typical cases of using Redux. This tool is not a universal solution in each of the possible cases of using Redux, but it allows you to simplify the code that the developer needs to write.

In this article, we will talk about the main tools included in the Redux Toolkit, and also, using an example of a fragment of our internal application, show how to use them in existing code.

Briefly about the library

Summary of Redux Toolkit:

  • before release, the library was called redux-starter-kit;
  • the release took place at the end of October 2019;
  • The library is officially supported by Redux developers.

According to the developers, the Redux Toolkit performs the following functions:

  • Helps you get started quickly using Redux.
  • simplifies work with typical tasks and Redux code;
  • allows you to use the best practices of Redux by default;
  • offers solutions that reduce distrust of boilerplates.

The Redux Toolkit provides a set of both specially designed and adds a number of well-proven tools that are commonly used with Redux. This approach allows the developer to decide how and which tools to use in their application. In the course of this article, we will note which borrowings this library uses. For more information and dependencies of the Redux Toolkit, see the @ reduxjs / toolkit package description.

The most significant features provided by the Redux Toolkit library are:

  • #configureStore – a function designed to simplify the process of creating and configuring storage;
  • #createReducer – a function that helps to concisely and clearly describe and create a reducer;
  • #createAction – returns the function of the creator of the action for the specified string of the type of action;
  • #createSlice – combines the functionality of createAction and createReducer;
  • createSelector is a function from the Reselect library, re-exported for ease of use.

It is also worth noting that the Redux Toolkit is fully integrated with TypeScript. For more information, see the Usage With TypeScript section of the official documentation.

Application

Consider using the Redux Toolkit library as an example of a fragment of a really used React Redux application.
Note. Further in the article, the source code will be presented both without using the Redux Toolkit and using it, which will allow to better evaluate the positive and negative aspects of using this library.

Task

In one of our internal applications, there was a need to add, edit and display information about the releases of our software products. For each of these actions, separate API functions were developed, the results of which are required to be added to the Redux store. As a means of controlling asynchronous behavior and side effects, we will use Thunk.

Storage creation

The initial version of the source code that creates the repository looked like this:

import {
  createStore, applyMiddleware, combineReducers, compose,
} from 'redux';
import thunk from 'redux-thunk';
import * as reducers from './reducers';

const ext = window .__ REDUX_DEVTOOLS_EXTENSION__;
const devtoolMiddleware =
  ext && process.env.NODE_ENV === 'development'? ext (): f => f;

const store = createStore (
 combineReducers ({
   ... reducers,
 }),
 compose (
   applyMiddleware (thunk),
   devtoolMiddleware
 )
);

If you carefully look at the code above, you can see a rather long sequence of actions that must be completed so that the repository is fully configured. The Redux Toolkit contains a tool designed to simplify this procedure, namely the configureStore function.

ConfigureStore function

This tool allows you to automatically combine reducers, add Redux middleware (it includes redux-thunk by default), and also use the Redux DevTools extension. The configureStore function accepts an object with the following properties as input parameters:

  • reducer – a set of custom reducers,
  • middleware – an optional parameter that specifies an array of middleware designed to connect to the repository,
  • devTools – a parameter of a logical type that allows you to enable the Redux DevTools extension installed in the browser (the default value is true),
  • preloadedState – an optional parameter that sets the initial state of the repository,
  • enhancers – an optional parameter that defines a set of amplifiers.

To get the most popular list of middleware, you can use the special function getDefaultMiddleware, which is also part of the Redux Toolkit. This function returns an array with middleware enabled by default in the Redux Toolkit library. The list of these middlewares differs depending on the mode in which your code is executed. In production mode, an array consists of only one element – thunk. In development mode, at the time of writing, the list is replenished with the following middleware:

  • serializableStateInvariant – a tool specifically designed for use in the Redux Toolkit and designed to check the state tree for the presence of non-serializable values, such as functions, Promise, Symbol and other values ​​that are not simple JS data;
  • immutableStateInvariant – middleware from the redux-immutable-state-invariant package, designed to detect mutations in the data contained in the storage.

To specify a ascending list of middleware, the getDefaultMidlleware function accepts an object that defines the list of included middleware and settings for each of them. More information on this information can be found in the corresponding section of the official documentation.

Now we’ll rewrite the part of the code responsible for creating the repository using the tools described above. As a result, we get the following:

import {configureStore, getDefaultMiddleware} from '@ reduxjs / toolkit';
import * as reducers from './reducers';

const middleware = getDefaultMiddleware ({
  immutableCheck: false,
  serializableCheck: false,
  thunk: true,
});

export const store = configureStore ({
 reducer: {... reducers},
 middleware
 devTools: process.env.NODE_ENV! == 'production',
});

Using this example code snippet as an example, the configureStore function solves the following problems:

  • the need to combine reducers, automatically calling combineReducers,
  • the need to combine middleware, automatically invoking applyMiddleware.

It also allows you to more conveniently enable the Redux DevTools extension using the composeWithDevTools function from the redux-devtools-extension package. All of the above indicates that using this function allows you to make the code more compact and understandable.

This completes the creation and configuration of the repository. We transfer it to the provider and go on.

Actions, action creators and reducer

Now let's look at the features of the Redux Toolkit in terms of developing actions, action creators and reducer. The initial version of the code without using the Redux Toolkit was organized as actions.js and reducers.js files. The contents of the actions.js file looked like this:

import * as productReleasesService from '../../services/productReleases';

export const PRODUCT_RELEASES_FETCHING = 'PRODUCT_RELEASES_FETCHING';
export const PRODUCT_RELEASES_FETCHED = 'PRODUCT_RELEASES_FETCHED';
export const PRODUCT_RELEASES_FETCHING_ERROR =
  'PRODUCT_RELEASES_FETCHING_ERROR';

...

export const PRODUCT_RELEASE_UPDATING = 'PRODUCT_RELEASE_UPDATING';
export const PRODUCT_RELEASE_UPDATED = 'PRODUCT_RELEASE_UPDATED';
export const PRODUCT_RELEASE_CREATING_UPDATING_ERROR =
  'PRODUCT_RELEASE_CREATING_UPDATING_ERROR';

function productReleasesFetching () {
  return {
    type: PRODUCT_RELEASES_FETCHING
  };
}

function productReleasesFetched (productReleases) {
  return {
    type: PRODUCT_RELEASES_FETCHED,
    productReleases
  };
}

function productReleasesFetchingError (error) {
  return {
    type: PRODUCT_RELEASES_FETCHING_ERROR,
    error
  }
}

...

export function fetchProductReleases () {
  return dispatch => {
    dispatch (productReleasesFetching ());
    return productReleasesService.getProductReleases (). then (
      productReleases => dispatch (productReleasesFetched (productReleases))
    ) .catch (error => {
      error.clientMessage = "Can't get product releases";
      dispatch (productReleasesFetchingError (error))
    });
  }
}

...

export function updateProductRelease (
  id, productName, productVersion, releaseDate
) {
  return dispatch => {
    dispatch (productReleaseUpdating ());
    return productReleasesService.updateProductRelease (
      id, productName, productVersion, releaseDate
    ) .then (
      productRelease => dispatch (productReleaseUpdated (productRelease))
    ) .catch (error => {
      error.clientMessage = "Can't update product releases";
      dispatch (productReleaseCreatingUpdatingError (error))
    });
  }
}

Contents of reducers.js file before using Redux Toolkit:

const initialState = {
 productReleases: [],
 loadedProductRelease: null,
 fetchingState: 'none',
 creatingState: 'none',
 updatingState: 'none',
 error: null,
};

export default function reducer (state = initialState, action = {}) {
 switch (action.type) {
   case productReleases.PRODUCT_RELEASES_FETCHING:
     return {
       ... state,
       fetchingState: 'requesting',
       error: null,
     };
   case productReleases.PRODUCT_RELEASES_FETCHED:
     return {
       ... state,
       productReleases: action.productReleases,
       fetchingState: 'success',
     };
   case productReleases.PRODUCT_RELEASES_FETCHING_ERROR:
     return {
       ... state,
       fetchingState: 'failed',
       error: action.error
     };

...

   case productReleases.PRODUCT_RELEASE_UPDATING:
     return {
       ... state,
       updatingState: 'requesting',
       error: null,
     };
   case productReleases.PRODUCT_RELEASE_UPDATED:
     return {
       ... state,
       updatingState: 'success',
       productReleases: state.productReleases.map (productRelease => {
         if (productRelease.id === action.productRelease.id)
           return action.productRelease;
         return productRelease;
       })
     };
   case productReleases.PRODUCT_RELEASE_UPDATING_ERROR:
     return {
       ... state,
       updatingState: 'failed',
       error: action.error
     };
   default:
     return state;
 }
}

As we can see, this is where most of the boilerplate is contained: action type constants, action creators, constants again, but in the reducer code, it takes time to write all this code. Part of this boilerplate can be eliminated by using the createAction and createReducer functions, which are also part of the Redux Toolkit.

CreateAction function

In the given section of code, the standard method for defining an action in Redux is used: first, a constant that defines the type of action is declared separately, and then the function of the creator of the action of this type is declared. The createAction function combines these two declarations into one. On input, it takes an action type and returns the creator of the action for that type. The action creator can be called either without arguments, or with some argument (payload), the value of which will be placed in the payload field of the created action. In addition, the action creator overrides the toString () function, so that the type of action becomes its string representation.

In some cases, you may need to write additional logic to adjust the value of the payload, for example, accept several parameters for the action creator, create a random identifier, or get the current time stamp. To do this, createAction takes an optional second argument – a function that will be used to update the payload value. More information about this parameter can be found in the official documentation.
Using the createAction function, we get the following code:

export const productReleasesFetching =
  createAction ('PRODUCT_RELEASES_FETCHING');
export const productReleasesFetched =
  createAction ('PRODUCT_RELEASES_FETCHED');
export const productReleasesFetchingError =
  createAction ('PRODUCT_RELEASES_FETCHING_ERROR');

...

export function fetchProductReleases () {
  return dispatch => {
    dispatch (productReleasesFetching ());
    return productReleasesService.getProductReleases (). then (
      productReleases => dispatch (productReleasesFetched ({productReleases}))
    ) .catch (error => {
      error.clientMessage = "Can't get product releases";
      dispatch (productReleasesFetchingError ({error}))
    });
  }
}
...

CreateReducer function

Now consider the reducer. As in our example, reducers are often implemented using the switch statement, with one register for each type of action processed. This approach works well, but is not without a boilerplate and error prone. For example, it is easy to forget to describe the default case or not to set the initial state. The createReducer function simplifies the creation of reducer functions by defining them as function search tables for processing each type of action. It also allows you to significantly simplify the logic of immutable updates by writing code in a "mutable" style inside reducers.

A “mutable" style of event handling is available through the use of the Immer library. The handler function can either “mutate” the state passed to change the properties, or return a new state, as when working in the immutable style, but, thanks to Immer, the real mutation of the object is not carried out. The first option is much easier for work and perception, especially when changing an object with deep nesting.

Be careful: returning a new object from a function overrides “mutable” changes. The simultaneous use of both state update methods will not work.

The createReducer function accepts the following arguments as input parameters:

  • initial state of storage
  • an object that establishes a correspondence between types of actions and reducers, each of which processes a certain type.

Using the createReducer method, we get the following code:

const initialState = {
 productReleases: [],
 loadedProductRelease: null,
 fetchingState: 'none',
 creatingState: 'none',
 loadingState: 'none',
 error: null,
};

const counterReducer = createReducer (initialState, {
 [productReleasesFetching]: (state, action) => {
   state.fetchingState = 'requesting'
 },
 [productReleasesFetched.type]: (state, action) => {
   state.productReleases = action.payload.productReleases;
   state.fetchingState = 'success';
 },
 [productReleasesFetchingError]: (state, action) => {
   state.fetchingState = 'failed';
   state.error = action.payload.error;
 },

...

 [productReleaseUpdating]: (state) => {
   state.updatingState = 'requesting'
 },
 [productReleaseUpdated]: (state, action) => {
   state.updatingState = 'success';
   state.productReleases = state.productReleases.map (productRelease => {
     if (productRelease.id === action.payload.productRelease.id)
       return action.payload.productRelease;
     return productRelease;
   });
 },
 [productReleaseUpdatingError]: (state, action) => {
   state.updating = 'failed';
   state.error = action.payload.error;
 },
});

As we can see, using the createAction and createReducer functions essentially solves the problem of writing extra code, but the problem of creating constants beforehand still remains. Therefore, we will consider a more powerful option that combines the generation of action creators and reducer – the createSlice function.

CreateSlice function

The createSlice function accepts an object with the following fields as input parameters:

  • name – namespace of created actions ($ {name} / $ {action.type});
  • initialState – initial state of the reducer;
  • reducers – an object with handlers. Each handler accepts a function with arguments state and action, action contains data in the payload property and the name of the event in the name property. In addition, it is possible to pre-modify the data received from the event before it gets into the reducer (for example, add id to the elements of the collection). To do this, instead of a function, you must pass an object with the reducer and prepare fields, where reducer is the action handler function, and prepare is the payload handler function that returns the updated payload;
  • extraReducers – an object containing reducers of another slice. This parameter may be required if it is necessary to update an object belonging to another slice. You can learn more about this functionality from the corresponding section of the official documentation.

The result of the function is an object called a "slice", with the following fields:

  • name – slice name,
  • reducer – reducer,
  • actions – a set of actions.

Using this function to solve our problem, we get the following source code:

const initialState = {
 productReleases: [],
 loadedProductRelease: null,
 fetchingState: 'none',
 creatingState: 'none',
 loadingState: 'none',
 error: null,
};

const productReleases = createSlice ({
 name: 'productReleases',
 initialState,
 reducers: {
   productReleasesFetching: (state) => {
     state.fetchingState = 'requesting';
   },
   productReleasesFetched: (state, action) => {
     state.productReleases = action.payload.productReleases;
     state.fetchingState = 'success';
   },
   productReleasesFetchingError: (state, action) => {
     state.fetchingState = 'failed';
     state.error = action.payload.error;
   },

...

   productReleaseUpdating: (state) => {
     state.updatingState = 'requesting'
   },
   productReleaseUpdated: (state, action) => {
     state.updatingState = 'success';
     state.productReleases = state.productReleases.map (productRelease => {
       if (productRelease.id === action.payload.productRelease.id)
         return action.payload.productRelease;
       return productRelease;
     });
   },
   productReleaseUpdatingError: (state, action) => {
     state.updating = 'failed';
     state.error = action.payload.error;
   },
 },
});

Now we will extract the action creators and reducer from the created slice.

const {actions, reducer} = productReleases;

export const {
  productReleasesFetched, productReleasesFetching,
  productReleasesFetchingError,
...
  productReleaseUpdated,
  productReleaseUpdating, productReleaseUpdatingError
} = actions;

export default reducer;

The source code of the action creators containing the API calls has not changed, except for the method of passing parameters when sending actions:

export const fetchProductReleases = () => (dispatch) => {
 dispatch (productReleasesFetching ());
 return productReleasesService
   .getProductReleases ()
   .then ((productReleases) => dispatch (productReleasesFetched ({productReleases})))
   .catch ((error) => {
     error.clientMessage = "Can't get product releases";
     dispatch (productReleasesFetchingError ({error}));
   });
};

...

export const updateProductRelease = (id, productName, productVersion, releaseDate) => (dispatch) => {
 dispatch (productReleaseUpdating ());
 return productReleasesService
   .updateProductRelease (id, productName, productVersion, releaseDate)
   .then ((productRelease) => dispatch (productReleaseUpdated ({productRelease})))
   .catch ((error) => {
     error.clientMessage = "Can't update product releases";
     dispatch (productReleaseUpdatingError ({error}));
   });

The above code shows that the createSlice function allows you to get rid of a significant part of the boilerplate when working with Redux, which allows you to not only make the code more compact, concise and understandable, but also spend less time writing it.

Total

At the end of this article, I would like to say that despite the fact that the Redux Toolkit library does not add anything new to storage management, it provides a number of much more convenient means for writing code than before. These tools allow not only to make the development process more convenient, understandable and faster, but also more effective, due to the presence in the library of a number of well-proven tools. We, Inobitek, plan to continue to use this library in the development of our software products and to monitor new promising developments in the field of Web technologies.

Thanks for attention. We hope that our article will be useful. More information about the Redux Toolkit library can be obtained from the official documentation.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *