Latest Post: Mastering Redux Toolkit: The Ultimate Guide for Frontend Developers

Mastering Redux Toolkit: The Ultimate Guide for Frontend Developers

Redux Toolkit simplifies state management by reducing boilerplate and making state management more intuitive. In this article, we'll explore how configureStore works, how to manage multiple slices, how to fetch data efficiently, and when Redux is the right choice.

25 min read
Redux Logo with some big cartoon muscles.

Apart from being the nightmare of any Junior Frontend Developer, Redux is a powerful state management tool. Yeah, okay, true, it has a reputation for being complex. But that’s why Redux Toolkit (RTK) simplifies this by reducing boilerplate and making state management more intuitive. In this article, we’ll explore how configureStore works, how to manage multiple slices, how to fetch data efficiently, and when Redux is the right choice. Let’s dive in!

This article is a good second step after you’ve read the How To Update Old React’s Projects Guide article.

Did you know that you can use redux with Svelte too?

Let me bring you to this repo where you will see the final result of this post but with Svelte!

Check out the Svelte version

At the end of this article, we will achieve the state of this repository, so feel free to check it out if you get lost! We will have components using the global state, two different ways to fetch data from an API, and tests for the components and the API. Also, I will show you how to use the Redux DevTools Extension to debug your application’s state changes. This is the final result:

Redux Terminology

These are some of the terms that are usually associated with Redux, just in case you need a bit of help.

Store

The container that holds all of your Redux application’s state. It’s created with configureStore and serves as the single source from which you read or update global state.

Reducer

A pure function that determines how to change state in response to an action. It takes the previous state and the action, and returns the new state.

Action

An object that describes “what happened” in the application. By convention, it has a type property (for instance, increment or logout) that indicates the type of event.

Dispatch

The way to send an action to the store. When you dispatch an action, Redux calls the appropriate reducer to update the state accordingly.

Thunk

A function that’s dispatched just like any other action, but instead of immediately updating the state, it can run asynchronous logic first and then dispatch a real action to the reducer.

createAsyncThunk

A Redux Toolkit method to simplify async logic. It creates thunks that automatically handle pending, fulfilled, and rejected states.

Slice

A subsection of the store that handles a set of related actions and state. Created with createSlice, it contains reducers and their associated actions.

configureStore

A function from Redux Toolkit that creates the store in a simpler way. It automatically sets up middlewares like type checking, thunk support, and Redux DevTools integration.

Provider

A React component that wraps your entire application, giving all child components access to the store without prop drilling.

useSelector

A React-Redux hook to read a specific piece of state from the store within a React component.

useDispatch

A React-Redux hook that returns the dispatch function, which is used to dispatch actions or thunks from a React component.

RTK Query

A feature of Redux Toolkit that simplifies API communication (HTTP requests, caching, and invalidation). It automatically generates hooks for sending and receiving data.

When Do You Really Need Redux?

Many developers wonder if they actually need Redux. The answer depends on how global your state is. You might benefit from Redux if:

  • Multiple components need access to the same state.

  • Your app has complex logic, such as authentication or API calls.

  • You need debugging tools or undo/redo functionality.

  • You want a single source of truth for UI state, cache, or form data.

If your state is only used in one or two components, React’s built-in useContext may be enough. If the complexity is a bit higher, something like Zustand or Jotai would suffice. But if you need a global state with some of the use cases mentioned aboved, Redux is a good choice.

Let’s keep it simple: think of Redux as a Toy Box

  • Redux allows to have a big box (store) where you can put all your toys.
  • The box is big, and that’s why you will have a part of the box for each toy (slice). For example, dinoSlice, carSlice, and so on.
  • Obviously you can do things with your toys inside the box, you can add new ones, remove them, or even change them. But for that, you need instructions (reducers). Inside every slice, you will have a reducer that will tell you how to play with the toys (for example: how to add them, remove them, change them…).

Hope this helped you get an idea, let’s move on and try to think of more day to day examples, like complex states and API calls. I prepared this repository so that you can follow along with the examples.

To keep your Redux logic organized, it’s a good practice to separate slices, API calls, and the store setup. I will be adding the tests folders and files later, but for the sake of simplicity, let’s focus on the Redux store and components.

/src
├── store/
│ ├── store.ts # The main Redux store configuration
│ ├── slices/
│ │ ├── counterSlice.ts # Counter slice
│ │ ├── userSlice.ts # User authentication slice
│ ├── api/
│ │ ├── userApi.ts # RTK Query API configuration
├── components/
│ ├── Counter.tsx # Counter component
│ ├── UserProfile.tsx # User Profile component using RTK Query

Why This Structure?

  • /slices/*/ stores state logic and actions.
  • /api/*/ keeps API-related logic separate for better maintainability.
  • store.ts imports everything and combines it in one place.
  • components/*/ keep the UI logic clean and separated from state management.

First steps: install the dependencies, create the store and add the provider

Let’s start by installing the necessary dependencies:

npm install @reduxjs/toolkit react-redux

Next, create the store configuration in store/store.ts:

import { configureStore } from '@reduxjs/toolkit'

export const store = configureStore({
  reducer: {},
})

Now, add the Provider to your App.tsx, main.tsx or index.tsx file. Since Provider is a pretty common name, I decided to rename it to ReduxProvider.

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { store } from './store/store.ts'
import { Provider as ReduxProvider } from 'react-redux'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ReduxProvider store={store}>
      <App />
    </ReduxProvider>
  </StrictMode>,
)

Great! The whole app has access to our global state / store, but we cannot do much for now, since the store is empty. Let’s fix this by adding a counterSlice to the store.

Adding a Counter Slice

Let’s create a counterSlice in store/slices/counterSlice.ts:

import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
  },
})

export const { increment, decrement } = counterSlice.actions
export default counterSlice.reducer

As we can see in the code above, we have a counterSlice with an initial state of { value: 0 } and two reducers: increment and decrement. These reducers will increase or decrease the value of the state by 1.

Now, let’s add the counterSlice to the store configuration in store/store.ts:

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './slices/counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
})

Let me also export, for the sake of type safety, some types that we will use in the components:

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './slices/counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

This way we are adding the counterSlice to the store configuration. Now, we can use the increment and decrement actions in our components. Like we can do in the Counter.tsx component:

import { useSelector, useDispatch } from 'react-redux'
import { decrement, increment } from '../store/slices/counterSlice'
import { RootState } from '../store/store'

function Counter() {
  const count = useSelector((state: RootState) => state.counter.value)

  const dispatch = useDispatch()

  return (
    <div>
      <h3>Counter: {count}</h3>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
    </div>
  )
}

export default Counter

Let’s add our Counter component to the App.tsx component. And not only that, let’s also show the value of the counter in the App.tsx component, so that we can see if the state is being updated correctly in both components.

By using the useSelector hook, we can access the counter slice state in the App.tsx component. We can also use the Counter component to increment and decrement the counter value by dispatching the increment and decrement actions.

import './App.css'
import { useSelector } from 'react-redux'
import Counter from './components/Counter'
import { RootState } from './store/store'

function App() {
  const count = useSelector((state: RootState) => state.counter.value)

  return (
    <main>
      <h1>Let's discuss Redux </h1>
      <section>
        <h2>Cases with simple stores</h2>
        <div>I will count for you too: {count}</div>
        <Counter />
      </section>
    </main>
  )
}

export default App

This is the result of the code above. Find the styling in the repo. I did it inline to keep the focus on the Redux logic.

Okay, cool, we have our first slice working. Let’s keep this momentum and add a userSlice to manage user authentication.

Pixel Art Banner Image
See my last creations!

Subscribe to my newsletter and get access to exclusive content, early access to templates, and more!

See more

Adding a User Slice

Let me do my magic at the beginning with the types, so that we can have a better understanding of the state and actions that we will have in the userSlice.

import { createSlice } from '@reduxjs/toolkit'

export type UserStatus = 'idle' | 'loading' | 'succeeded' | 'failed'

// ✅ Define UserState interface
export interface UserState {
  isLoggedIn: boolean
  name: string
  status: UserStatus
}

// ✅ Define Initial State with the correct type
const initialState: UserState = {
  isLoggedIn: false,
  name: '',
  status: 'idle',
}

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    login: (state, action: PayloadAction<string>) => {
      state.isLoggedIn = true
      state.name = action.payload
    },
    logout: (state) => {
      state.isLoggedIn = false
      state.name = ''
      state.status = 'idle' // Reset status on logout
    },
  },
})
export const { login, logout } = userSlice.actions
export default userSlice.reducer

How about that? Now we have a userSlice with an initial state of { isLoggedIn: false, name: "", status: "idle" } and two reducers: login and logout. The login reducer will set isLoggedIn to true and name to the payload value. The logout reducer will set isLoggedIn to false, name to an empty string, and status to idle.

Let’s add the userSlice to the store configuration in store/store.ts:

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './slices/counterSlice'
import userReducer from './slices/userSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
})

By the way, the configureStore is also the place where you can add the devTools and middlewares. For example, if you want to add the Redux DevTools, you can do it like this:

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './slices/counterSlice'
import userReducer from './slices/userSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
  devTools: process.env.NODE_ENV !== 'production',
})

Then, to get more info in the devTools, you need to install the Redux DevTools Extension. That helps you debug application’s state changes.

Here you have a video of how it works (Spoiler alert for the next section!):

But coming back to where we were… Our userSlice is cool, but let’s be fair, it is shouting for some API calls. Let’s add some API calls to the userSlice using createAsyncThunk and adding extraReducers.

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'

export type UserStatus = 'idle' | 'loading' | 'succeeded' | 'failed'

// ✅ Define UserState interface
export interface UserState {
  isLoggedIn: boolean
  name: string
  status: UserStatus
}

// ✅ Define Initial State with the correct type
const initialState: UserState = {
  isLoggedIn: false,
  name: '',
  status: 'idle',
}

// ✅ Async thunk to fetch user data
export const fetchUser = createAsyncThunk<string, void>(
  'user/fetchUser',
  async () => {
    const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
    const data = await response.json()
    return data.name // Only return the user's name
  },
)

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    login: (state, action: PayloadAction<string>) => {
      state.isLoggedIn = true
      state.name = action.payload
    },
    logout: (state) => {
      state.isLoggedIn = false
      state.name = ''
      state.status = 'idle' // Reset status on logout
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = 'loading'
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.isLoggedIn = true // Automatically log in after fetching user
        state.name = action.payload
      })
      .addCase(fetchUser.rejected, (state) => {
        state.status = 'failed'
      })
  },
})

export const { login, logout } = userSlice.actions
export default userSlice.reducer

In the code above, we added an extraReducers field to the userSlice. This field contains three cases: fetchUser.pending, fetchUser.fulfilled, and fetchUser.rejected. These cases handle the state changes when the fetchUser async thunk is pending, fulfilled, or rejected.

Now we can dispatch (amazing Redux term) the fetchUser async thunk in our components. Let’s add a UserComponent to the components folder (the CSS imported is BEM-styled, you can find it in the repo:

import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchUser, login, logout } from '../store/slices/userSlice'
import { AppDispatch, RootState } from '../store/store'
import './UserComponent.css' // Import the BEM-styled CSS

const UserComponent: React.FC = () => {
  const dispatch = useDispatch<AppDispatch>()
  const { isLoggedIn, name, status } = useSelector(
    (state: RootState) => state.user,
  )

  return (
    <div className="user">
      <h2 className="user__title">User Authentication</h2>
      <p className="user__status">
        Status: <strong>{status}</strong>
      </p>

      {isLoggedIn ? (
        <div>
          <p className="user__message">Welcome, {name || 'User'}!</p>
          <button
            className="user__button user__button--logout"
            onClick={() => dispatch(logout())}
          >
            Logout
          </button>
        </div>
      ) : (
        <div className="user__buttons">
          <button
            className="user__button user__button--login"
            onClick={() => dispatch(login('Manual User'))}
          >
            Login Manually
          </button>
          <button
            className="user__button user__button--fetch"
            onClick={() => dispatch(fetchUser())}
            disabled={status === 'loading'}
          >
            {status === 'loading' ? 'Fetching...' : 'Fetch User'}
          </button>
        </div>
      )}
    </div>
  )
}

export default UserComponent

You can see here how we are using some of the actions and the state from the userSlice. We are also using the fetchUser async thunk to fetch user data from the JSONPlaceholder API. The fetchUser async thunk will update the state based on the API call status.

We can now add the UserComponent to the App.tsx component:

import './App.css'
import { useSelector } from 'react-redux'
import Counter from './components/Counter'
import UserComponent from './components/UserComponent'
import { RootState } from './store/store'

function App() {
  const count = useSelector((state: RootState) => state.counter.value)

  return (
    <main>
      <h1>Let's discuss Redux </h1>
      <section>
        <h2>Cases with simple stores</h2>
        <div>I will count for you too: {count}</div>
        <Counter />
      </section>
      <section>
        <h2>Cases with API calls</h2>
        <UserComponent />
      </section>
    </main>
  )
}

export default App

This is the result of the code above.

And while this approach works for some simple cases, it can get messy when you have multiple slices and async thunks. That’s where Redux Toolkit Query (RTK Query) comes in. RTK Query simplifies API calls and caching, making it easier to manage complex data fetching logic. Let’s see together how to create an API call with RTK Query. First, let’s create a file called userApi.ts in the store/api folder.

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

export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  endpoints: (builder) => ({
    // Fetch a single user
    fetchUser: builder.query<{ id: number; name: string }, void>({
      query: () => '/users/1',
    }),
  }),
})

// Export the auto-generated hook for the `fetchUser` query
export const {
  useFetchUserQuery,
  useLazyFetchUserQuery, // ✨ Use the lazy hook
} = userApi

This code above creates an API client with (for the moment) a single endpoint to fetch a user from the JSONPlaceholder API. The fetchUser query will fetch a user with the ID of 1. Both the useFetchUserQuery and the useLazyFetchUserQuery hooks are auto-generated hooks that you can use to fetch user data in your components. The useLazyFetchUserQuery hook is a lazy hook that you can use to fetch user data only when needed, this is a good practice to avoid unnecessary API calls, and it is exactly what we want.

Let’s create another slice for the users, this time using this API we just created. Create a file called userSliceWithCreateAPI.tsx in the store/slices folder.

import { createSlice } from '@reduxjs/toolkit'
import { userApi } from '../api/userApi'

const userSliceWithCreateAPI = createSlice({
  name: 'userWithCreateAPI',
  initialState: {
    isLoggedIn: false,
    user: null as any | null, // ✅ Store the full user object
    status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null as string | null,
  },
  reducers: {
    login: (state, action) => {
      state.isLoggedIn = true
      state.user = { name: action.payload } // ✅ Ensure user object exists
      state.error = null
    },
    logout: (state) => {
      state.isLoggedIn = false
      state.user = null
      state.status = 'idle'
      state.error = null
    },
  },
  extraReducers: (builder) => {
    builder
      .addMatcher(userApi.endpoints.fetchUser.matchPending, (state) => {
        state.status = 'loading'
        state.error = null
      })
      .addMatcher(
        userApi.endpoints.fetchUser.matchFulfilled,
        (state, action) => {
          state.status = 'succeeded'
          state.isLoggedIn = true
          state.user = action.payload // ✅ Store full user object
          state.error = null
        },
      )
      .addMatcher(
        userApi.endpoints.fetchUser.matchRejected,
        (state, action) => {
          state.status = 'failed'
          state.error = action.error?.message || 'Failed to fetch user'
        },
      )
  },
})

export const { login, logout } = userSliceWithCreateAPI.actions
export default userSliceWithCreateAPI.reducer

In the code above, we created a userSliceWithCreateAPI slice that uses the userApi to fetch user data. Similar with the previous case, the slice has an initial state with isLoggedIn, user, status. This time I also added the error field. The login and logout reducers set the isLoggedIn and user fields based on the payload value. The extraReducers field contains three cases: fetchUser.matchPending, fetchUser.matchFulfilled, and fetchUser.matchRejected. These cases handle the state changes when the fetchUser query is pending, fulfilled, or rejected.

Now we need to reconfigure a bit the store to add this slice and the API configuration. Let’s update the store.ts file:

import { configureStore } from '@reduxjs/toolkit'
import counterSlice from './slices/counterSlice'
import userSlice from './slices/userSlice'
import userSliceWithCreateAPI from './slices/userSliceWithCreateAPI'
import { userApi } from './api/userApi'

export const store = configureStore({
  reducer: {
    counter: counterSlice,
    user: userSlice,
    userCreateAPI: userSliceWithCreateAPI, // ✨ Add the new slice
    [userApi.reducerPath]: userApi.reducer, // ✨ Add the API reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(userApi.middleware), // ✨ Add the API middleware
  devTools: process.env.NODE_ENV !== 'production',
})

// Extracting RootState and AppDispatch types
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

And finally we can use it in our new component, UserComponentWithCreateAPI.tsx:

import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { RootState } from '../store/store'
import { useLazyFetchUserQuery } from '../store/api/userApi'
import './UserComponent.css'
import { login, logout } from '../store/slices/userSliceWithCreateAPI'

const UserComponentWithCreateAPI: React.FC = () => {
  const dispatch = useDispatch()
  const { isLoggedIn, user, status, error } = useSelector(
    (state: RootState) => state.userCreateAPI,
  )

  const [fetchUser, { isFetching }] = useLazyFetchUserQuery()

  return (
    <div className="user">
      <h2 className="user__title">User Authentication</h2>
      <p className="user__status">
        Status: <strong>{status}</strong>
      </p>

      {error && <p className="user__error">Error: {error}</p>}

      {isLoggedIn ? (
        <div>
          <p className="user__message">
            Welcome, <strong>{user?.name || 'User'}</strong>!
          </p>
          <button
            className="user__button user__button--logout"
            onClick={() => dispatch(logout())}
          >
            Logout
          </button>
        </div>
      ) : (
        <div className="user__buttons">
          <button
            className="user__button user__button--login"
            onClick={() => dispatch(login('Manual User'))}
          >
            Login Manually
          </button>
          <button
            className="user__button user__button--fetch"
            onClick={() => fetchUser()} // ✅ Trigger API call on demand
            disabled={isFetching}
          >
            {isFetching ? 'Fetching...' : 'Fetch User'}
          </button>
        </div>
      )}
    </div>
  )
}

export default UserComponentWithCreateAPI

As we discussed earlier, we are using the useLazyFetchUserQuery hook to fetch user data only when needed (and not when mounting, for example). The fetchUser function triggers the API call, and the isFetching variable indicates whether the API call is in progress. The UserComponentWithCreateAPI component uses the userCreateAPI slice to manage user data and the login and logout actions to update the state. I added a bit of delay in the video to show the loading state.

And that’s basically it! Let me show you how I added a bit of delay to the API call and how I added other end points to see how easy it is to add more API calls with RTK Query.

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

const delayedBaseQuery = async (args: any, api: any, extraOptions: any) => {
  await new Promise((resolve) => setTimeout(resolve, 2000)) // ⏳ Add 2-second delay
  return fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com' })(
    args,
    api,
    extraOptions,
  )
}

// RTK Query API for user-related operations
export const userApi = createApi({
  reducerPath: 'userApi',
  // baseQuery: fetchBaseQuery({ baseUrl: "https://jsonplaceholder.typicode.com" }),
  baseQuery: delayedBaseQuery, // 🕒 Use the custom base query
  endpoints: (builder) => ({
    // Fetch a single user
    fetchUser: builder.query<{ id: number; name: string }, void>({
      query: () => '/users/1',
    }),

    // Fetch multiple users
    fetchUsers: builder.query<{ id: number; name: string }[], void>({
      query: () => '/users',
    }),

    // Fetch user by ID (dynamic)
    fetchUserById: builder.query<{ id: number; name: string }, number>({
      query: (id) => `/users/${id}`,
    }),

    // Update user (PUT request)
    updateUser: builder.mutation<
      { id: number; name: string },
      { id: number; name: string }
    >({
      query: ({ id, name }) => ({
        url: `/users/${id}`,
        method: 'PUT',
        body: { name },
      }),
    }),

    // Delete user
    deleteUser: builder.mutation<{ success: boolean }, number>({
      query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE',
      }),
    }),
  }),
})

// Export the auto-generated hook for the `fetchUser` query
export const {
  useFetchUserQuery,
  useLazyFetchUserQuery, // ✨ Use the lazy hook
  useFetchUsersQuery,
  useFetchUserByIdQuery,
  useUpdateUserMutation,
  useDeleteUserMutation,
} = userApi

Testing: The Cherry on Top

One forgotten aspect of Redux is testing. But with Redux Toolkit, testing is a breeze. You can test your slices, async thunks, and API calls with ease. And even though I think we should start by doing the tests to create our functions and not the other way around, I also consider that tests are excellent ways of documenting code and help others learn how things work!

Let’s start by installing everything we might need.

I will use vitest and @testing-library/react.

npm install @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom vitest msw --save-dev

Let me show the kind of structure I will use for tests in this project! This might differ from the structure you are used to. We could also have a tests folder in the root and separate everything nicely! Up to you! I will test the API and the components.

/src
├── store/
│ ├── store.ts  
│ ├── slices/
│ │ ├── counterSlice.ts
│ │ ├── userSlice.ts  
│ │ ├── userSliceWithCreateAPI.ts  
│ ├── api/
│ │ ├── **tests**  
│ │ │ ├── userApi.test.ts # RTK Query API tests
│ │ ├── userApi.ts  
├── components/
│ │ ├── **tests**  
│ │ │ ├── Counter.test.ts # Counter component tests
│ │ │ ├── UserComponent.test.ts # User Component component tests
│ ├── Counter.tsx  
│ ├── UserComponent.tsx  
│ ├── UserComponentWithCreateAPI.tsx  
├── tests/
│ ├── server.ts # Mock Service Worker server
│ ├── setup.ts # Jest setup file
│ ├── setupApiStore.ts # Mock store with the API reducer
│ ├── setupTestStore.ts # Mock stores with the slices
│ vite.config.ts # Vite configuration

Setup needed for testing

Let’s check all these files and see what they do!

// 📁 vite.config.ts
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/tests/setup.ts',
  },
})
// 📁 tests/server.ts
import { http } from 'msw'
import { setupServer } from 'msw/node'

const mockUser = { id: 1, name: 'Leanne Graham' }
const mockUsers = [
  { id: 1, name: 'User1' },
  { id: 2, name: 'User2' },
]

// ✅ Mock API handlers
export const handlers = [
  http.get('https://jsonplaceholder.typicode.com/users/1', (_info) => {
    return new Response(JSON.stringify(mockUser))
  }),
  http.get('https://jsonplaceholder.typicode.com/users', (_info) => {
    return new Response(JSON.stringify(mockUsers))
  }),
  http.get('https://jsonplaceholder.typicode.com/users/:id', ({ params }) => {
    const userId = Number(params.id)
    const user = mockUsers.find((user) => user.id === userId)
    return user
      ? new Response(JSON.stringify(user))
      : new Response(null, { status: 404 })
  }),
  http.put(
    'https://jsonplaceholder.typicode.com/users/:id',
    async ({ request, params }) => {
      const userId = Number(params.id)
      const { name } = (await request.json()) as { name: string }
      return new Response(JSON.stringify({ id: userId, name }))
    },
  ),
  http.delete('https://jsonplaceholder.typicode.com/users/:id', (_info) => {
    return new Response(JSON.stringify({ success: true }))
  }),
]

// ✅ Setup mock server
export const server = setupServer(...handlers)
// 📁 tests/setup.ts
import '@testing-library/jest-dom'

import { server } from './server'
import { beforeAll, afterEach, afterAll } from 'vitest'

// Start the API mock server before tests
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // Reset after each test
afterAll(() => server.close()) // Cleanup after all tests
// 📁 tests/setupApiStore.ts
import { configureStore } from '@reduxjs/toolkit'

export function setupApiStore(api: any) {
  const store = configureStore({
    reducer: { [api.reducerPath]: api.reducer },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(api.middleware),
  })

  return store
}
// 📁 tests/setupTestStore.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../store/slices/counterSlice' // Import default reducer
import userReducer, { UserStatus } from '../store/slices/userSlice' // Import default reducer
import { RootState } from '../store/store'

export function setupCounterStore(preloadedState?: {
  counter: RootState['counter']
}) {
  return configureStore({
    reducer: { counter: counterReducer },
    preloadedState: preloadedState ?? { counter: { value: 0 } }, // ✅ Ensure initial state is always defined
  })
}

export function setupUserStore(preloadedState?: { user: RootState['user'] }) {
  return configureStore({
    reducer: { user: userReducer },
    preloadedState: preloadedState ?? {
      user: { isLoggedIn: false, name: '', status: 'idle' as UserStatus },
    }, // ✅ Ensure default matches UserState
  })
}

With all these files, we can now test our components and API calls. Let’s start by testing the Counter component. Create a file called Counter.test.tsx in the components/__tests__ folder:

import { render, screen, fireEvent } from '@testing-library/react'
import { Provider } from 'react-redux'
import { describe, it, expect, beforeEach } from 'vitest'
import { setupCounterStore } from '../../tests/setupTestStore'
import Counter from '../Counter'

describe('Counter Component', () => {
  let store: ReturnType<typeof setupCounterStore>

  beforeEach(() => {
    store = setupCounterStore({ counter: { value: 0 } }) // Initialize store with counter at 0
  })

  it('renders with initial value', () => {
    render(
      <Provider store={store}>
        <Counter />
      </Provider>,
    )

    expect(screen.getByText('Counter: 0')).toBeInTheDocument()
  })

  it("increments the counter when '+' button is clicked", () => {
    render(
      <Provider store={store}>
        <Counter />
      </Provider>,
    )

    const incrementButton = screen.getByText('+')
    fireEvent.click(incrementButton)

    expect(screen.getByText('Counter: 1')).toBeInTheDocument()
  })

  it("decrements the counter when '-' button is clicked", () => {
    store = setupCounterStore({ counter: { value: 1 } }) // Start with 1 so we can decrement
    render(
      <Provider store={store}>
        <Counter />
      </Provider>,
    )

    const decrementButton = screen.getByText('-')
    fireEvent.click(decrementButton)

    expect(screen.getByText('Counter: 0')).toBeInTheDocument()
  })
})

I would add now a script in the package.json to run the tests easily.

"scripts": {
    ...
    "test": "vitest"
}

Try to run the tests with npm run test and see if everything is working as expected.

This is how you can test the Counter component. You can test the initial rendering, incrementing, and decrementing of the counter value. The setupCounterStore function initializes the store with the counter value at 0. You can also test the UserComponent component. Create a file called UserComponent.test.tsx in the components/__tests__ folder:

import { beforeEach, describe, expect, it } from 'vitest'
import { server } from '../../tests/server'
import { Provider } from 'react-redux'
import UserComponent from '../UserComponent'
import { fireEvent, render, waitFor, screen } from '@testing-library/react'
import { setupUserStore } from '../../tests/setupTestStore'

describe('UserComponent', () => {
  let store: ReturnType<typeof setupUserStore>

  beforeEach(() => {
    store = setupUserStore({
      user: { isLoggedIn: false, name: '', status: 'idle' },
    })
    server.resetHandlers() // Reset API mocks before each test
  })

  it('renders with initial state', async () => {
    render(
      <Provider store={store}>
        <UserComponent />
      </Provider>,
    )

    await waitFor(() => {
      expect(screen.getByText('Status:', { exact: false })).toBeInTheDocument()
    })

    expect(screen.getByText('Fetch User')).toBeInTheDocument()
  })

  it('logs in manually', () => {
    render(
      <Provider store={store}>
        <UserComponent />
      </Provider>,
    )

    const loginButton = screen.getByText('Login Manually')
    fireEvent.click(loginButton)

    expect(screen.getByText('Welcome, Manual User!')).toBeInTheDocument()
  })

  it('logs out successfully', () => {
    store = setupUserStore({
      user: { isLoggedIn: true, name: 'Manual User', status: 'idle' },
    })

    render(
      <Provider store={store}>
        <UserComponent />
      </Provider>,
    )

    const logoutButton = screen.getByText('Logout')
    fireEvent.click(logoutButton)

    expect(screen.getByText('Fetch User')).toBeInTheDocument()
  })

  it('fetches user and updates UI', async () => {
    render(
      <Provider store={store}>
        <UserComponent />
      </Provider>,
    )

    const fetchButton = screen.getByText('Fetch User')
    fireEvent.click(fetchButton)

    await waitFor(() => {
      expect(screen.getByText('Status:', { exact: false })).toHaveTextContent(
        'loading',
      )
    })

    await waitFor(() => {
      expect(
        screen.getByText((content) =>
          content.includes('Welcome, Leanne Graham'),
        ),
      ).toBeInTheDocument()
    })
  })
})

Again everything should be greener than the grass on the other side! 🌱

Let’s switch from components to API tests.

import { describe, it, expect, beforeEach } from 'vitest'
import { setupApiStore } from '../../../tests/setupApiStore'
import { userApi } from '../userApi'

describe('userApi', () => {
  let store: ReturnType<typeof setupApiStore> = setupApiStore(userApi)

  beforeEach(() => {
    store = setupApiStore(userApi)
  })

  it('fetches a single user', async () => {
    const result = await store.dispatch(userApi.endpoints.fetchUser.initiate())
    expect(result.data).toEqual({ id: 1, name: 'Leanne Graham' })
  })

  it('fetches multiple users', async () => {
    const result = await store.dispatch(userApi.endpoints.fetchUsers.initiate())
    expect(result.data).toEqual([
      { id: 1, name: 'User1' },
      { id: 2, name: 'User2' },
    ])
  })

  it('fetches a user by ID', async () => {
    const result = await store.dispatch(
      userApi.endpoints.fetchUserById.initiate(2),
    )
    expect(result.data).toEqual({ id: 2, name: 'User2' })
  })

  it('updates a user', async () => {
    const result = await store.dispatch(
      userApi.endpoints.updateUser.initiate({ id: 1, name: 'Updated User' }),
    )
    expect(result.data).toEqual({ id: 1, name: 'Updated User' })
  })

  it('deletes a user', async () => {
    const result = await store.dispatch(
      userApi.endpoints.deleteUser.initiate(1),
    )
    expect(result.data).toEqual({ success: true })
  })
})

And that’s it! You can test your API calls with RTK Query using the setupApiStore function. The userApi object contains the fetchUser, fetchUsers, fetchUserById, updateUser, and deleteUser endpoints. You can test these endpoints by dispatching the initiate function and checking the result data.

Summary

In this article, we learned how to use Redux Toolkit to manage state in React applications. We started by creating a simple counter slice with actions and reducers. We then added a user slice with async thunks to fetch user data from an API. Finally, we used RTK Query to simplify API calls and caching.

Conclusion

Redux Toolkit removes the complexity of Redux, making state management simple and scalable. Whether you need basic state management, async data fetching, or powerful API handling with RTK Query, Redux Toolkit is the perfect solution.

Now, go build something amazing with Redux Toolkit! 🚀

Next.js Banner Image
Do you want to prototype your ideas faster?

Check out my latest Nextjs 15 template! Done with motion, shadcn, support for several languages, newsletter, etc!

See more

FAQ about Redux Toolkit

Biceps flex arm vector isolated on white background Vectors by Vecteezy


Share article