Redux ToolKit Guide

Published on

This is my guide to using Redux with Redux Toolkit, which is a more modern way of using Redux and provides a lot of helpers (and correctly types things in Typescript very easily).

Table of Contents

Slices

These are a very useful and neat concept in Redux Toolkit.

They are like a structure (data structure/shape) for your state data that you want to store in Redux. You set up the initial state, reducers and the name for the 'slice'.

A slice is often a part of your site, e.g. users-slice.ts, blog-post-slide.ts

Its always a good idea to use some kind of structure like:

type BlogPosts {
    items: BlogPost[]
}

instead of just type BlogPosts = BlogPost[].

The reason for that is so if you need to replace all items with a new item, you can update state.items = [] or state.items = await someApiCall().

If your state with just an array, you have to do something like:

state.length = 0;
state.push(...await someApiCall())

The reason for this is because RTK uses Immer. It watches for changes in an object. this is different than regular React state (via useState()) or regular redux - you don't have to return a new object when updating state. You can mutate the object but more on this later.

In smaller apps, they tend to live in ./src/features/something-slice.ts.

Here is an example

import { createSlice } from '@reduxjs/toolkit';

interface BlogPost {
    title: string,
    slug: string,
    body: string,
}

export type BlogPostState = {
  items: BlogPost[]
}

const initialState: BlogPostState = {
  items: []
}

const blogSlice = createSlice({
  name: 'blog',
  initialState,
  reducers: {
    addPost: (state) => state, // incomplete - i'll work on this further down the post
  }
});

// now the following will autocomplete thanks to the typing:
// Type for this is ActionCeatorWithoutPayload (as there is no payload arg in the addPost defined above)
tasksSlice.actions.addTask() // incomplete : we would need to add new blog post details

In the example above, which is incomplete, it has one reducer to mutate the state but its incomplete. It doesn't mutate the state, and it doesn't take any payload arguments.

(There are some cases which may not need any payload arguments. Maybe addPost could be one - if it adds an empty post.)

If you define the reducers with two args - (state, payload) then we can use that payload to update the state.

When it gets passed to the reducer implementation (within createSlice()), it gets transformed, so you have the state argument (current state - you can mutate this), and then the "PayloadAction" as the 2nd param. This has the shape like:

{
    type: "blog/addPost",
    payload: T, // T is the shape of your payload
}

The payload can be any shape, string, number, bool, an object, an array etc.

You could have this for example to add a new blog post with some dummy content:


const blogSlice = createSlice({
  name: 'blog',
  initialState,
  reducers: {
    addPost: (state, action: PayloadAction<BlogPost>) => {
        // we have typed with PayloadAction<BlogPost>
        // so it will autocomplete things like:
        // action.payload.title (from BlogPost['title']) etc
        console.log(action.type) // "blog/addPost"
        
        // but we care about action.payload
        state.items.push(action.payload)
    },
  }
})

createAction

The createAction function in Redux Toolkit is there to help you create a Redux action type & creator

This is a useful function if you are migrating from Redux to Redux Toolkit.

The 'old' way to define an action is like this:

const INCREMENT = 'counter/increment'

function increment(amount: number) {
  return {
    type: INCREMENT,
    payload: amount,
  }
}

const action = increment(5)
// { type: 'counter/increment', payload: 5 }

But you can use createAction to generate both the action name (const INCREMENT...) and the creator (the function that returns the type/payload).

import { createAction } from '@reduxjs/toolkit'

const increment = createAction<number | undefined>('counter/increment')

let action = increment();
// { type: 'counter/increment' }

action = increment(5);
// returns { type: 'counter/increment', payload: 5 }

console.log(`The action type for increment is "${increment}""`);
// 'The action type for increment "counter/increment"'

There are also prepare callbacks which can be used if you need to generate some parameters. For example, if you wanted to have a action where you just pass in a string param for a blog title, but wanted to fill out the rest of the blog too (add a default blog body, and add a created at time):

import { createAction, nanoid } from '@reduxjs/toolkit'

const addTodo = createAction('blog/add', function prepare(title: string) {
  return {
    payload: {
      body: "Example first paragraph of a blog post",
      title,
      createdAt: new Date().toISOString(),
    },
  }
})

You can use these actions with createReducer. Remember that the .toString() on the actions returns the name (like counter/increment in the example above), so we can pass in the action to .addCase() when creating a reducer:

import { createAction, createReducer } from '@reduxjs/toolkit'

const decrement = createAction<number>('counter/decrement')
const increment = createAction<number>('counter/increment')


const counterReducer = createReducer(0, (builder) => {
  // passing in decrement will work here, as `String(decrement)` will return "counter/decrement"`
  builder.addCase(decrement, (state, action) => state - action.payload)
  builder.addCase(increment, (state, action) => state + action.payload)
})


createReducer

(Some aspects of createReducer is covered in the createAction() part on this page)

You can create a reducer with createReducer().

First arg is the initial state, second argument is the reducer function.

The reducer function is done via the builder and using addCase()

// similar to the example for createAction, but this time we're hard
// coding the type to DECREMENT/INCREMENT
const counterReducer = createReducer(0, (builder) => {
  builder.addCase('DECREMENT', (state, action) => state - action.payload)
  builder.addCase('INCREMENT', (state, action) => state + action.payload)
})

There are 3 methods on builder

  • addCase is like a switch statement's case. You pass in two args - the string to compare ('DECREMENT'), and the action to run (the function that returns the mutated / new state). This works with strings (and will call .toString() if it is not a string - which is why you can pass in an action (from createAction()) as that has .toString() setup to return its type. )
  • addDefaultCase is like a switch statement's default. It will run the action if it didn't match any other cases. This is an optional function that you don't have to setup
  • addMatcher - You can use addCase when the action.type exactly matches the string that you pass in to the 1st arg on addCase. If you need more complex logic, then use addMatcher which can run a comparison function. If this fn returns true, then it matches and the action fn runs. An example of when this might be useful is if you want to do something like return action.type.endsWith('failed') instead of just exactly matching the string 'failed'. Also useful for thunks (mentioned elsewhere on this page)

Updating from an action on another slice (with extraReducers)

Sometimes you might have one reducer doing something, that ideally should call another reducer from a different slice).

An example: When you delete a user, you might want to remove all blogPosts written by that user.

You can do this with extraReducers when creating a slice.

interface User {
  id: string, // uuid
  email: string
}

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    addUser: (state, action: PayloadAction<User>) => {
      state.users.push(action.payload)
    },
    deleteUser: (state, action: PayloadAction<string>) => {
      state.users = state.users.filter(user => user.id !== action.payload)
    },
  }
})

// this would be exported, but for this demo I'm putting everything in same file
const deleteUserAction = userSlice.actions.deleteUser

interface BlogPost {
  title: string, 
  createdByUserId: string, // uuid
}

const blogSlice = createSlice({
  name: 'blog',
  initialState,
  reducers: {
    addPost: (state, action: PayloadAction<BlogPost>) => {
        state.items.push(action.payload)
    },
  },
  
  // This is the part we're looking at:
  extraReducers: (builder) => {
    // when 'deleteUserAction' is ran, which is from the users slice:
    builder.addCase(deleteUserAction, (state, action) => {
      // remove any blog posts created by the user that we are deleting:
      const userThatWasDeleted = action.payload; // uuid
      state.items.filter(post => post.createdByUserId !== userThatWasDeleted)
    })
  }
})


Creating a store with configureStore()

Store often lives in /lib in many modern react apps (although I still personally prefer to keep them in /src/lib)


// (blogSlice defined above in this blog post)
const blogReducer = blogSlice.reducer

const store = configureStore({
    reducer: {
        blog: blogReducer
        // you can add multiple reducers here
    }
})

Then you can use the Provider from react-redux, and pass in the store object (from my last code example), in your App.tsx:

import { Provider } from 'react-redux';
import store from './src/lib/store';

root.render(
    <Provider store={store}>
        <YourApp />
    </Provider>
);

Now you can use your store with the regular useDispatch etc:

Add these imports to a React component:

import { useDispatch } from 'react-redux';
import { addBlogPost } from '../features/blog-slice';

Then in your component, something like this:

export function AddBlogPost() {
    const dispatch = useDispatch();
    
    return <button onClick={(event) => {
           event.preventDefault();
           dispatch(addBlogPost({
               title: "A new post",
               body: "some demo text",
           }))
    }}>Add</button>;
}

Using useSelector

If you import useSelector (and useDispatch) from react-redux, you can start using these hooks to access your store.

import { useSelector, useDispatch } from 'react-redux'

Then you can do something like this:

const numItems = dispatch(useSelector(state => state.items.length)

But first lets get it typed correctly

In your main store.ts (where you had const store = configureStore(...)), make a new exported type. We can use the return type of the store.getState() function:

// store.ts
const store = configureStore(...);

// export the full state
export type BlogState = ReturnType<typeof store.getState>

// export the type for dispatch() calls:
export type BlogDispatch = typeof store.dispatch

This means now we could use useSelector and use the correct state:

// your-component.ts
import { BlogState } from "./store.ts";

// ...

const numItems = dispatch(
    useSelector<BlogState>(
        state => state.items.length
    )
);

But there is a nicer pattern to this - we can create our own custom hook for useSelector that has our typings. Instead of directly using useSelector (and typing as <BlogState> every time), you can just create a new export from your store.ts

export const useBlogSelector = (selector: (state: BlogState, action) => void) => useSelector(selector)
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { BlogState, BlogDispatch } from './store'

export const useBlogSelector: TypedUseSelectorHook<BlogState> = useSelector
export const useBlogDispatch: () => BlogDispatch = useDispatch

All the above does is export useSelector/useDispatch (from react-redux), but wraps it in new typings so any consumers of useBlogSelector/useBlogDispatch will now have correct typings from your BlogState type.

Async functionality

Very often you will want to do async API calls and update your redux state with the data in the response.

RTK Query (RTKQ)

RTK Query is a powerful data fetching and caching tool. It is designed to simplify common cases for loading data in a web application, eliminating the need to hand-write data fetching & caching logic yourself.

RTK Query is an optional addon included in the Redux Toolkit package, and its functionality is built on top of the other APIs in Redux Toolkit.

If you are familiar with React Query or SWR, then you should get these concepts quite quickly.

Note: You can use RTK Query by itself (in a similar way to React Query), and you don't have to use RTK Query if you're using React Toolkit. If you use RTK and RTK query, then your budle size will increase (vs just using RTK). In my demo i'll import from @reduxjs/toolkit/query/react but you can import the non React version from @reduxjs/toolkit/query

Example usage:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const blogApi = createApi({
  // works like reducers:
  endpoints: builder => {
    return {
      getPosts: builder.query({
        query: () => 'all'
      })
    }
  },
  baseQuery: fetchBaseQuery({ baseUrl: '/api/blog' }),
  reducerPath: 'blogApi',
})

You can then use it in configureStore():

// store.ts:

configureStore({
  reducer: {
    // ... your other reducers
    [blogApi.reducerPath]: blogApi.reducer,
  },
})

I recommend reading up on it here: https://redux-toolkit.js.org/rtk-query/usage/examples

createAsyncThunk

An alternative to RTK query is to create a thunk. Its quite easy to set up and use - but if you can get away with using RTK query then that is what i'd recommend.

A function that accepts a Redux action type string and a callback function that should return a promise. It generates promise lifecycle action types based on the action type prefix that you pass in, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.

This abstracts the standard recommended approach for handling async request lifecycles.

It does not generate any reducer functions, since it does not know what data you're fetching, how you want to track loading state, or how the data you return needs to be processed. You should write your own reducer logic that handles these actions, with whatever loading state and processing logic is appropriate for your own app.

I'll show with a demo - first creating the async thunk:

import { createAsyncThunk } from 'redux-toolkit';

export const getAllBlogPosts = createAsyncThunk('blog/all', async () => {
  const res = await fetch('/api/blog/all')
  // (for demo: no error checking)
  return res.json();
})

Then you set it up with the extraReducers:



const blogSlice = createSlice({
  name: 'blog',
  initialState,
  reducers: {
    // ...
  },

  extraReducers: (builder) => {
    // can set it up for pending, fulfilled, rejected
    builder.addCase(getAllBlogPosts.fulfilled, (state, action) => {
      state.items = action.payload
    })
  }
})

You can dispatch it with dispatch(getAllBlogPosts())

General notes

Behind the scenes RTK uses the Immer library. This is useful as it means you can mutate objects, and Immer knows what changed. Unlike normally with React state or Redux where you have to be very careful to always return a new object.

(If you have deeply nested objects in useState(), you have to spread multiple level to update some great-grand-child item. But with Immer it tracks these changes and is fast, and will just return a fresh new object when/if required)

Notes on testing with redux toolkit

  • I am a strong believer that you should not test implementation details. Its not always a good idea to directly be testing your redux/store related functions. Its best to test them via testing a component.
  • But if you insist on testing the slices, its quite easy with RTK.

(this is a demo of how to test RTK, not best testing practices or how to use jest.)

Example of creating an action, running that action through the reducer (with a demo initial state):

import { addBlogPost, blogReducer } from './blog-slice'

const createMockBlogPost = (overide?: Partial<BlogPost>): BlogPost => {
  return {
    title: "Example title",
    body: "Some body",
    ...override
  }
}

const initialState = () => {
  return ({
    items: [
      createMockBlogPost({ title: "Post 1" }),
      createMockBlogPost({ title: "Post 2" }),
    ]
   })
;

describe('blog-slice', function() {
  test('adds a task', () => {
    const newTask = createMockBlogPost({ title: "new" })
    const action = addBlogPost(newTask); // << this is not dispatched yet, it just creates the 'action' with payload/type

    const result = blogReducer(initialState(), action) // << now it runs the code, based on action

    expect(result.items).toHaveLength(3); // (real test: we'd want to verify the specific items are in there)
  })
});