Simplifying Redux with the Redux Toolkit

No Comments

Modern web applications usually do not only have an UI, but also a state that has to be kept and maintained in the system. One of the most popular libraries for state management is Redux. With the help of Redux, a global store is created which holds the state of the application and makes it accessible. Redux offers a good base for the beginning. However, it does not provide enough functions for all use cases on its own. One way to simplify working with Redux is the Redux Toolkit. This toolkit provides some useful libraries and has comfort functions to make the syntax of Redux a bit more understandable. Also the code becomes a bit more compact because default values are used for many things.

The Redux Toolkit is flexible and can be combined with normal Redux without problems. You can use the functions that make sense in the current use case and for the remaining application the standard methods of Redux are used.

Configuration made easy

To configure Redux, the createStore function is used. This function expects a reducer function, an optional initial state and a set of enhancer functions, which are also optional, as arguments. When the store is created, there are normally different enhancers that extend Redux with required functions. In addition, you often need different enhancers for the development mode and production.

To simplify the configuration, the Redux Toolkit offers a configureStore function that provides useful default values such as standard enhancers when creating the store. The function expects a configuration object. The configuration object tries to map the parameters of the createStore function in a way that is better understandable.

Various properties of the store are defined in the configuration object. The following parameters can be used:

  1. reducer: An object is passed, which consists of different keys with reducer functions as values. The root reducer is then automatically formed from these individual reducer functions. Internally the Redux Toolkit uses the combineReducers function for this purpose.
  2. middleware: A set of middleware functions can be specified as middleware that extends the store with additional functionality. If no middleware is specified, the getDefaultMiddleware function is used to load some standard middlewares. The default middlewares are thunk, immutableStateInvariant, and serializableStateInvariant. The last two middleware are only loaded in development mode.
  3. devTools: If a Boolean is passed, it determines whether the ReduxDevTools should be configured automatically or not. If you pass an object, it will be forwarded to the composeWithDevTools function. If no parameter is given, “true” is the default value.
  4. preloadedState: An optional initial state that is passed to the store.
  5. enhancers: An optional list of enhancers. These enhancers are passed internally to the createStore method. The applyMiddleware and composeWithDevTools enhancers should not be passed here as they are already managed by the configureStore method.

An example of configuring a Redux store with different reducers, middlewares, support for the dev tools, an initial state and an enhancer is seen in the following example.

const reducer = {
  todos: todosReducer,
  visibility: visibilityReducer
}

const middleware = [...getDefaultMiddleware(), logger]

const preloadedState = {
  todos: [
    {
      text: 'Eat food',
      completed: true
    }
  ],
  visibilityFilter: 'SHOW_COMPLETED'
}

const store = configureStore({
  reducer,
  middleware,
  devTools: process.env.NODE_ENV !== 'production',
  preloadedState,
  enhancers: [reduxBatch]
})

Mutative reducers

The second function offered by the Redux Toolkit is createReducer, which helps write simpler reducers. The first advantage of this method is that no switch statement is needed anymore. The switch statement approach works fine, but it creates a certain boilerplate and you can easily make mistakes such as forgetting the default case or missing the initial state. The createReducer function expects two parameters: an initial state and an object with the action types as keys and the assigned reducer function as value. Here is the comparison of a normal Redux reducer to the createReducer function:

function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'increment':
      return state + action.payload
    case 'decrement':
      return state - action.payload
    default:
      return state
  }
}

const counterReducer = createReducer(0, {
  increment: (state, action) => state + action.payload,
  decrement: (state, action) => state - action.payload
})

The second advantage of the createReducer function is that the state in the reducer no longer needs to be used as immutable objects. You can now work in the reducer as if you would change the state directly. The Redux Toolkit generates the new state from the direct changes to the old state. The library immer (https://github.com/immerjs/immer) is used for this. The reducer receives a proxy state that translates all changes into equivalent copy operations. The reducers that are written with immer are 2 to 3 times slower than a normal Redux reducer. Here is an example of a Reducer function that uses the state as immutable and one which makes changes directly to the state.

case: {
  const newFilter = state.currentFilter.includes(filterParameter)
    ? state.currentFilter.filter(filter => filter !== filterParameter)
    : [...state.currentFilter, filterParameter];
  return {
    ...state,
    currentFilter: newFilter,
  };
}

[UPDATE_FILTER]: (state, action) => {
  if(state.currentFilter.includes(filterParameter)) {
    state.currentFilter = state.currentFilter.filter(filter => filter !== filterParameter)
  } else {
    state.currentFilter.add(filterParameter)
  }
}

The advantage of making changes directly on the element is that the code is easier to understand. The focus is on the change and the rules how the change occur, thus the developer does not need to consider how the new state is actually built.

The disadvantage of this is that you can bypass the basic rules of Redux and errors can occur if you accidentally make such changes in a normal reducer. Also, the performance is slightly worse, but this should not be relevant in most cases.

Actions with less boilerplate

To create an action in Redux, you usually need a constant for the type and an ActionCreator that uses this constant. This leads to additional boilerplate code. The createAction method of the Redux Toolkit combines these two steps into one. Here is an example of a normal Action Creator and how it can be created with the Redux Toolkit.

const INCREMENT = 'counter/increment'
function increment(amount) {
  return {
    type: INCREMENT,
    payload: amount
  }
}
const action = increment(3)
// { type: 'counter/increment', payload: 3 }
const increment = createAction('counter/increment')
action = increment(3)
// returns { type: 'counter/increment', payload: 3 }

This function helps remove most of the boilerplate code. One disadvantage is that you can no longer define what format the payload should have. With generated actions the payload is not defined in more detail and you have to check in the reducer, which has to be passed as payload.

Generating actions automatically

If you want to go one step further, the actions for a store can also be generated automatically. For this purpose the Redux Toolkit offers the createSlice method. The method expects as parameters a slice name, an initial state and a set of reducer functions.

The createSlice method then returns a set of generated ActionCreators that match the keys in the Reducer. The generated actions can be given the appropriate payload and can be used easily. However, generating these actions reaches its limits relatively quick. It is no longer possible to map asynchronous actions, for example, with Redux thunk. The following example shows how to use the functions and the resulting action creators.

const user = createSlice({
  name: 'user',
  initialState: { name: '', age: 20 },
  reducers: {
    setUserName: (state, action) => {
    state.name = action.payload
    },
    increment: (state, action) => {
    state.age += 1
    }
  },
})

const reducer = combineReducers({
  user: user.reducer
})
const store = createStore(reducer)
store.dispatch(user.actions.increment())
// -> { user: {name : '', age: 21} }
store.dispatch(user.actions.increment())
// -> { user: {name: '', age: 22} }
store.dispatch(user.actions.setUserName('eric'))
// -> { user: { name: 'eric', age: 22} }

The createSlice method can greatly reduce the required boilerplate for some stores. However, it is not really flexible and it must be checked whether it is suitable for the specific application.

Selectors with Reselect

Another library that is included in the Redux Toolkit is Reselect. With the help of Reselect materialized selectors can be written which facilitate the access to the store and can improve the performance.

A Reselect selector derives a new value from the state of the store. This allows for easy access to derived elements from the state. All elements that can be derived do not have to be held in the state itself, which means that the store does not become unnecessarily large. The Reselect selectors are not recalculated every time the store is changed but only if their input parameters change. This can provide an additional performance advantage.

The individual selectors can also be combined, so that the result of one selector is the input of the next. Here is an example of how the selectors can be used.

const shopItemsSelector = state => state.shop.items
const taxPercentSelector = state => state.shop.taxPercent

const subtotalSelector = createSelector(
  shopItemsSelector,
  items => items.reduce((acc, item) => acc + item.value, 0)
)

const taxSelector = createSelector(
  subtotalSelector,
  taxPercentSelector,
  (subtotal, taxPercent) => subtotal * (taxPercent / 100)
)

First there are two selectors, which simply return single elements from the state. Afterwards you can use the subtotalSelector to get the total price of all elements from the shop items. The taxSelector now combines the existing selectors to calculate the taxes.

Conclusion

The Redux Toolkit provides a number of functions that can simplify working with Redux. It covers many standard cases by default, but can still be configured for more specific tasks. The Redux Toolkit provides a collection of libraries that are already widely used and work well together. With these libraries and the new features of the Redux Toolkit, a lot of boilerplate code can be avoided and the syntax becomes a bit clearer and easier to understand.

The advantages of the Redux Toolkit come at a price. You get an additional dependency through the Redux Toolkit and indirectly some dependencies through the libraries provided by the toolkit. Furthermore, the standards in the Redux Toolkit relieve the developer of some tasks. Thus, when dealing with Redux without the toolkit, knowledge gaps might arise. Especially for new developers, basic rules in working with Redux are abstracted, which might make it more difficult to gain a better understanding of Redux and the Flux pattern.

Whether the Redux toolkit provides more advantages than disadvantages is up for discussion. However, switching a project to the Redux Toolkit is possible without any problems. The individual functions keep using the normal Redux functionality internally, so the individual parts of the store can also be iteratively converted to the toolkit and this does not have to be done in a major conversion. In addition, it is not always necessary to use the full range of functions of the Redux Toolkit, but you can limit yourself to those functions that provide an advantage in the current situation.

Enno Lohmann

Enno has worked for codecentric AG since 2018. His focus is on fullstack Java development with Spring Boot and web technologies.

Comment

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