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.

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.
Let me bring you to this repo where you will see the final result of this post but with Svelte!
Check out the Svelte versionAt 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:
These are the topics we will cover in this article:
- Redux Terminology
- When Do You Really Need Redux?
- Let’s keep it simple: think of Redux as a Toy Box
- Recommended File Structure
- First steps: install the dependencies, create the store and add the provider
- Adding a Counter Slice
- Adding a User Slice
- Adding a User Slice with createAPI
- Testing: The Cherry on Top
- Summary
- Conclusion
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
orlogout
) that indicates the type of event.- Dispatch
The way to send an action to the
store
. When you dispatch an action, Redux calls the appropriatereducer
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 withcreateSlice
, it containsreducers
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 everyslice
, you will have areducer
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.
Recommended File Structure
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.

Subscribe to my newsletter and get access to exclusive content, early access to templates, and more!
See moreAdding 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! 🚀

Check out my latest Nextjs 15 template! Done with motion, shadcn, support for several languages, newsletter, etc!
See moreFAQ about Redux Toolkit
Biceps flex arm vector isolated on white background Vectors by Vecteezy
Share article