Simplify and standardize your Redux configuration with Redux Toolkit

by
Tags: , ,
Category:

Are you using Redux to manage state in your React application, or thinking of rolling out Redux to simplify the prop passing and component state in your project?

The Redux team has developed a toolkit to simplify and standardize some of the techniques used to roll out a Redux-based solution. Known as the Redux Toolkit, it implements some design patterns that can help you write less code and organize it effectively.

Benefits to Redux Toolkit

So what are some of the key reasons to use Redux Toolkit over a bespoke Redux configuration?

  • Simplified reducers, using the Immer library
  • Support for async thunk out of the box
  • Querying support tool based on common async loading and status update patterns, and caching techniques

Let's look at these features in detail.

Recap: Writing a Redux Reducer

First, let's look at a reducer and action/action creator definition with plain vanilla Redux.

const defaultState = { ... };

// typically you'd create action keys as const strings
const START_GAME='start-game';

// then create the action creator function to generate the shape
// of the dispatched message to redux via the type key
export function startGame() {
  return { type: START_GAME };
}

// then create the reducer, inspecting the type to determine
// whether/how to replace the state
export default function reducer (state = defaultState, action) {
  switch (action.type) {
  case START_GAME:
      return {
    ...state,
    endGame: false,
    letterOptions: generateLetters(),
    wordList: [],
    usedLetterIndexes: []
  };
  // ... more cases here...

  // if no change, we just return state as-is
  default:
    return state;
  }
}

Remember, in Redux, we have to replace the state with a new object each time we process an action, if we want that state to change. States are immutable objects, and as such must be replaced for change detection to kick in and render updates to components on the React side.

Creating Reducers using Redux Toolkit

Redux toolkit wraps all reducer calls created with the createReducer or createSlice (which we’ll see below) with the Immer library, you can write this more imperatively:

// create-reducer.js
import { createAction, createReducer } from '@reduxjs/toolkit';

const defaultState = { ... };

// set up an action creator function and action type (the argument)
const startGame = createAction('start-game');

// create the reducer
const reducer = createReducer (
  { ... initial state object here ... },
  (builder) => {
    builder.addCase(startGame, (state, action) => {
      state.endGame = false;
      state.letterOptions = generateLetters();
      state.wordList = [];
      state.usedLetterIndexes = [];
    });
  // ... add more cases (action references) here...
  }
});

// now, export the reducer and startGame action creator
export default reducer;
export startGame;

Since the code will be wrapped with the Immer library's functional replacement features (as you’ve used Redux Toolkit’s createReducer), you can safely modify the passed state to the function. This avoids a lot of object spread operators and simplifies your code. However, the state is still safely replaced by Immer if you modified anything within it.

Read more about the createReducer function createReducer API documentation page.

State change limitations with Redux Toolkit’s Immer Usage

There is one key restriction with reducers written in Redux Toolkit. You may only:

  • Return a new state object, OR
  • Mutate the state object passed to the reducer

You cannot do both operations in the same reducer, so this is illegal:

state.endGame = false;
state.letterOptions = generateLetters();
return state;

Creating the store with Redux Toolkit

Now, you can create the store via Redux Toolkit, and let it give you sensible defaults:

// create-store.js
import { configureStore } from '@reduxjs/toolkit';
import reducer from './create-reducer.js';

export default const store = configureStore({ reducer });

And now you can do the rest of your configuration the same way as before, setting up a react-redux Provider, etc…

Breaking up a monolithic store

Typically in larger Redux applications, we use the combineReducers to put multiple reducers together:

// create-store.js
// with vanilla redux 
const rootReducer = combineReducers({
  timer: timerReducer,
  game: gameReducer
});

export default const store = createReducer(rootReducer);

This logically organizes the store into multiple reducers that manage their own part of the state. Each reducer can answer dispatched actions being sent to the store by replacing their state.

The Ducks Pattern posits that each logical slice of the store should be held within a single file, including actions, action creators and the reducer. But the Redux Toolkit makes this even easier to do.

Note: cross-cutting messages (such as logging, logout / login actions, application-specific lifecycle events) can be defined as shared actions and wired into any reducers that need to respond to them. These action keys and action creators are typically created in a separate cross-cutting / shared events folder. That is a topic for another blog post, however.

Creating a Store "slices" with Redux Toolkit

We’ve been showing our examples here placing the reducer, actions and action creators in the same file, following the Ducks pattern. Redux Toolkit follows this philosophy by making it easy to create actions/action creators. But the toolkit’s createSlice feature gives us even more power and flexibility in how we create our actions than plain vanilla Redux.

Redux Toolkit’s Slices are objects that contain the reducer, actions, and action creators for a given slice of logic rather than the entire redux store. They help automate the process of setting up a single slice of the store, with the same benefits of hand-coding a Ducks implementation.

The Game reducer we discussed earlier can be defined as a slice, like this:

// gameSlice.js
export const gameSlice = createSlice({
  name: 'game',
  initialState,
  reducers: {
    startGame: (state, action) => {
      state.gameStatus = 'inProgress';
      state.letterOptions = generateLetters();
      state.wordList = [];
      state.usedLetterIndexes = [];
    },
    chooseLetter: (state, action) => {
      state.usedLetterIndexes.push(action.payload);
    },
    removeLastLetter: (state, action) => {
      state.usedLetterIndexes.splice(state.usedLetterIndexes.length - 1, 1);
    },
    finishWord: (state, action) => {
      state.word = state.usedLetterIndexes.map((idx) => {
        return state.letterOptions[idx];
      }).join('');
      state.wordList.push(state.word)
      state.usedLetterIndexes = [];
    },
    reset: (state, action) => {
      state.gameStatus = 'idle';
      state.usedLetterIndexes = [];
    },
    setScore: (state, action) => {
      state.finalScore = action.payload;
    },
    setHighScorer: (state, action) => {
      state.highScorer = true;
    }
  }
});
...

Ultimately, this makes the reducer logic easier to navigate, since it breaks each of the traditional switch statement keys into their own reducer function.

Other slices may include a game timer function, managing high scorers, and other features. The main issue with slices and partitioned reducers is how to properly divide them up. See this part (and the rest) of the Redux Style Guide for some guidance on how to split up the store.

Exporting the action creators and reducer

Once this slice object is created, we can export the action creator functions and the reducer like this:

...
// action creators (callable with dispatch())
export const {
    startGame, 
    chooseLetter, 
    removeLastLetter, 
    finishWord, 
    reset, 
    setScore, 
    setHighScorer} = gameSlice.actions;

export default gameSlice.reducer;

As an added bonus, reducer logic is broken out into simple functions.

Now we can use the reducers generated by createSlice to assemble our store:

// store.js
import {configureStore} from '@reduxjs/toolkit'

import {combineReducers} from 'redux';
import gameReducer from './gameSlice';
import timerReducer from './timerSlice';

const store = configureStore({
    reducer: {
      game: gameReducer,
      timer: timerReducer
    },
    // other options
});

export default store;

Note the lack of combineReducers here. The Redux Toolkit automatically combines all reducers passed to the function into a single root reducer.

We also get the Redux DevTools, some common middleware, and other features enabled by default. See more at the configureStore documentation page.

Cross-cutting actions

The Redux Store API includes a few functions for creating actions in a stand-alone way. For example, you can create an action based on an action key:

export const reset = createAction('reset');

This actually creates an action creator – a function that generates the shape of the action when called:

const result = reset();
// shape:  { type: 'reset' }

// typically called in a dispatch method:

dispatch(reset());

If the call includes a parameter, that ends up in the payload property of the returned action shape:

dispatch(reset(true));
// shape:  { type: 'reset', payload: true }

The payload can be anything including an object shape.

Async actions with Thunk

Redux Toolkit automatically installs the Redux Thunk library, making dispatching actions asynchronously relatively painless. But it goes even further. There is a createAsyncAction function, which uses Thunk semantics. Here is a somewhat complex action creator that uses thunk to end a game, including checking whether the player's score is a high score for the system:

export const endGame = createAsyncThunk('game/endGame',
  // the function provided as the second parameter runs to completion, 
  // and once the promises resolved by the function are done, the thunk
  // is complete.
  async (payloadArgument, thunkAPI) => {  // payloadArgument if passed to method

    // thunk actions can asynchronously dispatch to the store
    const dispatch = thunkAPI.dispatch;

    // we can get the shortcut to Redux current state to derive our score
    const state = thunkAPI.getState();

    const game = state.game;            // get the game slice

    // get and store the score
    const finalScore = game.wordList ? game.wordList.length : 0;
    dispatch(setScore(finalScore));

    // async call in thunk action:  so we use await...
    // ask server for high scorers
    const highScorers = await getHighScorers();

    // take scorers and figure out if we're in the top scorers list
    const highScorersSize = highScorers ? highScorers.length : 0;
    const lowestHighScore =
          highScorers.reduce((last, current) => 
            current.score < last ? current.score : last, Number.MAX_VALUE);

    // ick...
    if (finalScore > 0 &&
      (highScorersSize === 0 || (highScorersSize < 5 || finalScore > lowestHighScore)))  {
      // yep, we're the high scorer, so set it
      dispatch(setHighScorer());
    }
});

How do we install this async action and make use of it? We can use the extraReducers property of the slice creation function:

export const gameSlice = createSlice({
  name: 'game',
  initialState,
  reducers: { ... },
  // add async actions here...
  extraReducers: (builder) => {
    builder.addCase(endGame.fulfilled, (state, action) => {
      state.gameStatus = 'idle';
    });
  }
...
});

Some Redux middleware, including Redux Promise Middleware is typically combined with Thunk middleware to track progress of an asynchronous call.

Redux Toolkit uses this pattern (not the Promise Middleware API, but dispatching the fulfilled, rejected and pending actions), and the example above uses the fulfilled action to trigger a change in the game status once the endGame async thunk action is completed successfully.

In the above example, once the function for the endGame async thunk action is complete, Redux Toolkit dispatches a game/endGame.fulfilled action, which we can use to signal that the game is no longer active.

Tracking an async action's lifecycle

In the example below, we add pending and rejected actions to track dispatching, success and failure of asynchronous activities:

export const gameSlice = createSlice({
  name: 'game',
  initialState,
  reducers: { ... },
  // add async actions here...
  extraReducers: (builder) => {
    builder.addCase(endGame.pending, (state, action) => {
      // clear any prior error message
      state.errorMessage = undefined;

      // track the active call
      state.scoring = true;
    });
    builder.addCase(endGame.fulfilled, (state, action) => {
      state.scoring = false;
      state.gameStatus = 'idle';
    });
    builder.addCase(endGame.rejected, (state, action) => {
      state.scoring = false;
      state.errorMessage = action.payload;
    });
  }
...
});

We can use the scoring prop to raise and lower a spinner, and the error message can be displayed where needed.

Wrap-up

We can use the Redux Toolkit to provide sensible middleware and defaults, make our reducers more readable, split apart those large reducer switch statements, and handle async operations with ease.

Alternatives to the Redux Toolkit include Redux Act and Redux Act Async and the aforementioned Ducks pattern. But the Redux Toolkit is written by Mark Erikson, who has been maintaining Redux, and is very well documented.

Are you using Redux Toolkit or something else beyond rolling your own Redux store? Let us know in the comments.