Mastering Redux Toolkit (Part 1 of 3): From Basics to RTK Query

Vayia Tourlida
Nerd For Tech
Published in
11 min readOct 13, 2023

What Redux Toolkit offers more than old Redux?

Redux Toolkit (RTK) enhances Redux development by:

  • Reducing Boilerplate: Elevates best practices by auto-generating reducers and actions.
  • Simplifying Complexity: Addresses Redux’s challenges with tools like createSlice.
  • Streamlining Async Operations: Integrated createAsyncThunk for handling async tasks.
  • Optimized State Management: Offers state slicing and integrates Immer for immutable updates.
  • Bundling Essential Tools: Comes packed with powerful middlewares like Redux Thunk, making Redux more accessible and maintainable.

Practical Example

Introduction

We’re on the brink of piecing together an engaging media app using Redux Toolkit, tailored specifically for songs and movies.

  • To elevate the realism of our app, we’ll lean on faker.js. This will inject our song and movie lists with realistic data, enhancing the overall feel.

Check out the mockup below. After you get a feel for it, we’ll dive into the fun coding part together!

Github code boilerplate: https://github.com/tourlida/redux-toolkit-examples/tree/main/rtk-basic-example-boilerplate

  1. Clone Github repo:
git clone https://github.com/tourlida/redux-toolkit-examples.git

2. Navigate to project directory:

cd rtk-basic-example-boilerplate

4. Install dependencies:

npm install

5. Run the app:

npm run dev

Now if you navigate at http://localhost:5173/ if everything works fine you should see the following:

Initially, we didn’t have any data.

Now, we’ll dive into Redux Toolkit and bring our app to life. Excited?

Let’s go!

  1. Install Redux Toolkit:
npm install @reduxjs/toolkit react-redux

2. Create a Redux Store:

After the installation is complete, create a file named src/store/index.jsx . Import configureStore from Redux Toolkit and then create an empty Redux store which will be exported you can see in the code below:

import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: {},
})
export { store };
  • This code sets up a Redux store and connects it to the Redux DevTools for easy debugging.
  • The reducer property you provide to configureStore is an object that maps slices of state to their corresponding reducers. Each key represents a specific slice of the overall state, and its associated value is the reducer that governs the behavior of that slice.

If it still seems unclear to you don’t worry we’ll dive deeper later.

3. Connect Redux Store to React app:

After you’ve created the store, you will have to wrap your <App/> with a <Provider/> which will be imported from react-redux. Also, the store you created above will be passed into the provider as a prop in order to make it available in our application. As we show below:

//main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { store } from "./store";
import { Provider } from "react-redux";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);

4. Create simple store slices:

Once the store is set up, create a directory at src/store/slices to house all slice files. Within this directory, create two files: moviesSlice.tsx and songsSlice.tsx for managing movies and song states respectively.

Slices, as introduced by Redux Toolkit, represent distinct segments of the Redux store. They’re designed to encompass three main aspects: an initial state, a set of action creators, and a reducer function. Essentially, think of each slice as a self-contained module responsible for a specific chunk of the application state.

The major advantages of using slices include:

  • Encapsulation: Each slice is a standalone entity, making state management more organized.
  • Modularity: Slices can be easily reused and combined, making your Redux setup more scalable.
  • Maintainability: By keeping related logic bundled together in slices, the code becomes more readable and easier to debug.

By structuring your application with slices, like the movie and song slices we just set up, you pave the way for a robust, efficient, and modular Redux architecture.

//moviesSlice.tsx
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { Movie } from "../../models";
import { MovieState } from "..";

const moviesSlice = createSlice({
name: "movie",
initialState: [] as MovieState,
reducers: {
// 'movie' + '/' + 'addMovie' = 'movie/addMovie'
addMovie(state: MovieState, action: PayloadAction<Movie>) {
state.push(action.payload);
},
// 'movie' + '/' + 'removeSong' = 'movie/removeSong'
removeMovie(state:MovieState, action: PayloadAction<Movie>) {
return state.filter((movie:Movie) => movie.id !== action.payload.id);
},
}
});

export const { addMovie, removeMovie } = moviesSlice.actions;
export const moviesReducer = moviesSlice.reducer;
timport {PayloadAction, createSlice } from "@reduxjs/toolkit";
import { Song } from "../../models";
import { SongsState } from "..";

const songsSlice = createSlice({
name: "song",
initialState: [] as SongsState,
reducers: {
// 'song' + '/' + 'addSong' = 'song/addSong'
addSong(state: SongsState, action: PayloadAction<Song>) {
state.push(action.payload);
},
// 'song' + '/' + 'removeSong' = 'song/removeSong'
removeSong(state: SongsState, action: PayloadAction<Song>) {
return state.filter((song:Song) => song.id !== action.payload.id);
}
},
});

export const { addSong, removeSong } = songsSlice.actions;
export const songsReducer = songsSlice.reducer;

Now let’s dive deeper to uncover the mechanisms behind Redux Toolkit’s createSlice.

When using Redux Toolkit’s createSlice, a lot of the boilerplate typically associated with Redux is conveniently abstracted away. Here's what's happening under the hood:

  • Slice Name: Acts as a prefix for actions, ensuring uniqueness. (e.g. With the slice name "song", actions will be prefixed like "song/actionName".)
  • Action Types: Automatically created by joining the slice name and the reducer key. (e.g. A reducer named addSong in the "song" slice generates the action type "song/addSong".)
  • Action Creators: Auto-generated for each reducer. When called, these return the action’s type and payload. ( e.g. The addSong action creator might look like addSong(payload) => { type: 'song/addSong', payload }. Using addSong('Jazz Blues') dispatches the action { type: 'song/addSong', payload: 'Jazz Blues' }
  • Reducers & Immer: Although reducers appear to mutate state directly, Redux Toolkit uses Immer underneath to ensure safe, immutable state updates.
  • Exports: The output from createSlice includes the action creators and the reducer, ready for dispatching actions and managing the state.

So using createSlice we are avoiding to write all the following code:

//Action Types
const ADD_SONG = 'song/addSong';
const REMOVE_SONG = 'song/removeSong';

//Action Creators
function addSong(payload) {
return {
type: ADD_SONG,
payload
};
}
function removeSong(payload) {
return {
type: REMOVE_SONG,
payload
};
}

//Reducers
const initialState = [];
function songsReducer(state = initialState, action) {
switch(action.type) {
case ADD_SONG:
return [...state, action.payload];
case REMOVE_SONG:
return state.filter(song => song !== action.payload);
default:
return state;
}
}

In a nutshell, createSlice handles the Redux boilerplate, linking actions and reducers via the slice name and ensuring safe state management.

5. Add slices to the store

The Reducers exported from the slices need to be imported and added to the store you created earlier. We also import all the actions and export them from this file and define a simple model for the RootState. This allows you to complete the configuration of the store. Here is how this can be done:

//src/store/index.tsx
import { configureStore } from "@reduxjs/toolkit";
import { addSong, removeSong, songsReducer } from "./slices/songsSlice";
import { addMovie, moviesReducer, removeMovie } from "./slices/moviesSlice";
import { Movie, Song } from "../models";

export type SongsState = Song[];
export type MovieState = Movie[];
export interface RootState {
songs: SongsState;
movies: MovieState;
}
const store = configureStore({
reducer: {
songs: songsReducer,
movies: moviesReducer,
},
});
export { store };
export { addSong, removeSong, addMovie, removeMovie };

5. Use the state & actions in your React components.

Up until now, you’ve just been going through the initial setup for Redux Toolkit, setting up the store, and creating the reducer. Now you need to start making use of the state and actions in your app to achieve the desired functionality.

You will be using two hooks: useDispatch and useSelector . Data are being read from the store through the useSelector hook while the actions are being dispatched using the useDispatch hook.

The corresponding actions (addSong, removeSong, addMovie, removeMovie) are being imported from the src/store/index.tsx file to be used by the dispatch.

When these buttons are clicked, two things happen:

  • The Redux action is dispatched to the store.
  • The slice reducer will see the action and then update the state.
//src/components/MoviesPlaylist.tsx
import {
Avatar,
Button,
Card,
CardContent,
CardHeader,
CardMedia,
Fade,
Grid,
IconButton,
Menu,
MenuItem,
Stack,
Typography,
} from "@mui/material";
import { createRandomMovie } from "../data";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { useState } from "react";
import DeleteIcon from "@mui/icons-material/Delete";
import LibraryMusicIcon from "@mui/icons-material/LibraryMusic";
import { Movie } from "../models";
import { useDispatch, useSelector } from "react-redux";
import { RootState, addMovie, removeMovie } from "../store";

function MoviesPlaylist() {
const [selectedMovie, setSelectedMovie] = useState<Movie | null>(null);


const dispatch = useDispatch();
const moviesPlaylist:Movie[] = useSelector((state:RootState)=>{
return state.movies;
});

const handleMovieAdd = (movie:Movie) => {
dispatch(addMovie(movie));
};
const handleMovieRemove = () => {
if (selectedMovie) {
dispatch(removeMovie(selectedMovie));
}
handleClose();
};
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);

const handleClick = (event: React.MouseEvent<HTMLElement>,movie:Movie) => {
setSelectedMovie(movie)
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};

const renderedMovies = moviesPlaylist.map((movie) => {
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={movie.id}>
<Card sx={{ maxWidth: 345 }} key={movie.id}>
<CardHeader
avatar={
<Avatar sx={{ bgcolor: "red" }} aria-label="recipe">
<LibraryMusicIcon />
</Avatar>
}
action={
<IconButton aria-label="settings" onClick={(e)=>handleClick(e,movie)}>
<MoreVertIcon />
</IconButton>
}
title="Shrimp and Chorizo Paella"
subheader="September 14, 2016"
/>
<CardMedia
sx={{ height: 140 }}
image={movie.imageUrl}
title="green iguana"
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{movie.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{movie.description}
</Typography>
</CardContent>
</Card>

<Menu
id="fade-menu"
MenuListProps={{
"aria-labelledby": "fade-button",
}}
style={{}}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
onClose={handleClose}
TransitionComponent={Fade}
>
<MenuItem
onClick={handleMovieRemove}
>
Delete <DeleteIcon sx={{ marginLeft: 1 }} />
</MenuItem>
</Menu>
</Grid>
);
});

return (
<div
style={{ width: "calc(100% - 32px)", margin: "auto", marginTop: "48px" }}
>
<Stack direction="row" justifyContent="space-between" mb={2}>
<Typography variant="h4">Movies Playlist</Typography>
<Button
onClick={() => handleMovieAdd(createRandomMovie())}
variant="contained"
startIcon={<AddIcon />}
sx={{ maxHeight: "42px", whiteSpace: "nowrap" }}
>
{" "}
Add movie
</Button>
</Stack>
<Grid container spacing={2} style={{ padding: "48px 0px" }}>
{renderedMovies}
</Grid>
</div>
);
}

export default MoviesPlaylist;
//src/components/SongsPlaylist.tsx
import {
Avatar,
Button,
Card,
CardContent,
CardHeader,
CardMedia,
Fade,
Grid,
IconButton,
Menu,
MenuItem,
Stack,
Typography,
} from "@mui/material";
import { createRandomSong } from "../data";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { useState } from "react";
import DeleteIcon from "@mui/icons-material/Delete";
import LibraryMusicIcon from "@mui/icons-material/LibraryMusic";
import { Song } from "../models";
import { RootState, addSong, removeSong } from "../store";
import { useDispatch, useSelector } from "react-redux";

function SongPlaylist() {
const [selectedSong, setSelectedSong] = useState<Song | null>(null);

const dispatch = useDispatch();
const songPlaylist: Song[] = useSelector((state: RootState) => {
return state.songs;
});

const handleSongAdd = (song:Song) => {
dispatch(addSong(song));
};

const handleSongRemove = () => {
if (selectedSong) {
dispatch(removeSong(selectedSong));
}
handleClose();
};

const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);

const handleClick = (event: React.MouseEvent<HTMLElement>, song: Song) => {
setSelectedSong(song);
setAnchorEl(event.currentTarget);
};

const handleClose = () => {
setAnchorEl(null);
};

const renderedSongs = songPlaylist.map((song) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={song.id}>
<Card sx={{ maxWidth: 345 }}>
<CardHeader
avatar={
<Avatar sx={{ bgcolor: "red" }} aria-label="recipe">
<LibraryMusicIcon />
</Avatar>
}
action={
<IconButton
aria-label="settings"
onClick={(e) => handleClick(e, song)}
>
<MoreVertIcon />
</IconButton>
}
title="Shrimp and Chorizo Paella"
subheader="September 14, 2016"
/>
<CardMedia
sx={{ height: 140 }}
image={song.imageUrl}
title="green iguana"
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{song.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{song.description}
</Typography>
</CardContent>
</Card>

<Menu
id="fade-menu"
MenuListProps={{
"aria-labelledby": "fade-button",
}}
anchorEl={anchorEl}
open={open}
onClose={handleClose}
TransitionComponent={Fade}
>
<MenuItem onClick={handleSongRemove}>
Delete <DeleteIcon sx={{ marginLeft: 1 }} />
</MenuItem>
</Menu>
</Grid>
));

return (
<div
style={{ width: "calc(100% - 32px)", margin: "auto", marginTop: "48px" }}
>
<Stack direction="row" justifyContent="space-between" mb={2}>
<Typography variant="h4">Songs Playlist</Typography>
<Button
onClick={() => handleSongAdd(createRandomSong())}
variant="contained"
startIcon={<AddIcon />}
sx={{ maxHeight: "42px", whiteSpace: "nowrap" }}
>
{" "}
Add Song
</Button>
</Stack>
<Grid container spacing={2} style={{ padding: "48px 0px" }}>
{renderedSongs}
</Grid>
</div>
);
}

export default SongPlaylist;

So far, we’ve imported actions like addSong and the others. Now, let's think of a bit more complex scenario: having multiple slices respond to a single action.

Redux Toolkit’s createSlice is brilliant at auto-generating actions for its own reducers. But what if you need a slice to react to actions from a different slice or an external source?

That’s where extraReducers come in!

For our app, consider the “Reset” button. When pressed, it should reset both the songs and movies lists. Instead of creating two separate reset actions for each slice, we can have both the songsSlice and moviesSlice respond to one universal reset action.

First, we need to create a universal reset action so we create a new file src/store/actions.tsx

//src/store/actions.tsx
import {
createAction,
} from "@reduxjs/toolkit";
const reset = createAction('app/reset');
export {reset};

Then update src/store/index.tsx file to import and export also the reset action as we show below:

//src/store/actions.tsx
import { configureStore } from "@reduxjs/toolkit";
import { addSong, removeSong, songsReducer } from "./slices/songsSlice";
import { addMovie, moviesReducer, removeMovie } from "./slices/moviesSlice";
import { Movie, Song } from "../models";
import { reset } from "./actions";

export type SongsState = Song[];
export type MovieState = Movie[];

export interface RootState {
songs: SongsState;
movies: MovieState;
}

const store = configureStore({
reducer: {
songs: songsReducer,
movies: moviesReducer,
},
});

export { store };
export { addSong, removeSong, addMovie, removeMovie };
export { reset };

Finally, we are updating our slices without our extraReducers:

//songsSlice.tsx
import {
ActionReducerMapBuilder,
PayloadAction,
createSlice,
} from "@reduxjs/toolkit";
import { Song } from "../../models";
import { SongsState, reset } from "..";

const songsSlice = createSlice({
name: "song",
initialState: [] as SongsState,
reducers: {
// 'song' + '/' + 'addSong' = 'song/addSong'
addSong(state: SongsState, action: PayloadAction<Song>) {
state.push(action.payload);
},
// 'song' + '/' + 'removeSong' = 'song/removeSong'
removeSong(state: SongsState, action: PayloadAction<Song>) {
return state.filter((song: Song) => song.id !== action.payload.id);
},
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
extraReducers(builder: ActionReducerMapBuilder<any>) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
builder.addCase(reset.type, (_state: any, _action: any) => {
return [];
});
},
});
export const { addSong, removeSong } = songsSlice.actions;
export const songsReducer = songsSlice.reducer;
//moviesSlice.tsx
import { ActionReducerMapBuilder, PayloadAction, createSlice } from "@reduxjs/toolkit";
import { Movie } from "../../models";
import { MovieState, reset } from "..";

const moviesSlice = createSlice({
name: "movie",
initialState: [] as MovieState,
reducers: {
// 'movie' + '/' + 'addMovie' = 'movie/addMovie'
addMovie(state: MovieState, action: PayloadAction<Movie>) {
state.push(action.payload);
},
// 'movie' + '/' + 'removeSong' = 'movie/removeSong'
removeMovie(state:MovieState, action: PayloadAction<Movie>) {
return state.filter((movie:Movie) => movie.id !== action.payload.id);
},
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
extraReducers(builder: ActionReducerMapBuilder<any>) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
builder.addCase(reset.type, (_state: any, _action: any) => {
return [];
});
},
});
export const { addMovie, removeMovie } = moviesSlice.actions;
export const moviesReducer = moviesSlice.reducer;

And dispatch our action when the Reset button is clicked:

import { Button, Divider } from "@mui/material";
import MoviePlaylist from "./components/MoviesPlaylist";
import SongPlaylist from "./components/SongsPlaylist";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { useDispatch } from "react-redux";
import { reset } from "./store";

export default function App() {
const dispatch = useDispatch();
const handleResetClick = () => {
dispatch(reset());
};
return (
<>
<Button
onClick={() => handleResetClick()}
variant="contained"
endIcon={<RestartAltIcon />}
sx={{ mb: 2 }}
>
{" "}
Reset Both Playlists
</Button>
<Divider />
<MoviePlaylist />
<Divider />
<SongPlaylist />
</>
);
}

The Result

This is exactly what you should get when you run your code:

Conclusion

Navigating through this article, we demonstrate some of the capabilities of Redux Toolkit for React:

  • Dived into Redux Toolkit (RTK) and its benefits for React apps.
  • Highlighted createSlice for easier reducer and action creation.
  • Touched on extraReducers for dynamic state interactions.

Overall, RTK simplifies and elevates the Redux experience, streamlining state management.

🌍 Live Demo: Check out the live demo of the project here.

📖 Codebase: Access the full code for this project on GitHub.

Thank you for taking the time to read through the article! 🙌 If you have any questions or feedback, please don’t hesitate to reach out at tourlidavagia@gmail.com I’d love to hear from you!

--

--