Mastering Redux Toolkit (Part 2 of 3): Introducing Async Thunks

Vayia Tourlida
Nerd For Tech
Published in
17 min readOct 20, 2023

Introduction

In the dynamic world of Redux Toolkit, fetching data typically revolves around two primary techniques: Async Thunks and RTQ Query. While both methods offer their unique advantages, this article will specifically focus on Async Thunks. In real-life applications, data isn’t always statically housed within our store. Instead, we often fetch them from sources, such as REST or GraphQL APIs. This process, although essential, introduces challenges. For instance, managing actions that require waiting for data like loading indicators or error messages becomes a critical concern. Transitioning from simple state management to accommodating external data sources adds layers of complexity. This is where thunks come into play.

Thunks allow us to write logic that can handle asynchronous actions smoothly, making it easier to manage states like “loading” or “error”.

Before diving into thunks, it’s essential to first understand Higher-Order Functions.

What is a High Order Function?

In functional programming, a higher-order function is one that can take another function as a parameter, return a function, or even do both. Here’s a basic example to illustrate this:

// Callback function, passed as a parameter in the higher order function
function callbackFunction(){
console.log('I am a callback function');
}

// higher order function
function higherOrderFunction(func){
console.log('I am higher order function')
func()
}

higherOrderFunction(callbackFunction);

What is a Thunk?

In traditional Redux, action creators return simple action objects. However, when we need more complex logic, especially for asynchronous operations, thunks step in. At a conceptual level, thunks in Redux try to mimic the logic of higher-order functions.

Similarly, a thunk is an action creator that doesn’t return an action object directly but instead returns a function. This returned function has the power to dispatch multiple actions, possibly asynchronously.

Redux Toolkit’s createAsyncThunk utility leverages this idea. By creating thunks tailored for asynchronous operations, we simplify the asynchronous logic, as seen in the example:

import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
const fetchUsers = createAsyncThunk('users/fetch', async ()=>{
const response = await axios.get('http://localhost:3005/users');
return response.data;
});

export {fetchUsers};

In the provided example, our thunk, which is created by createAsyncThunk, gives us back a promise. This mirrors the behavior of higher-order functions, which yield other functions. With promises having distinct states such as pending, fulfilledor rejected(Thunk Lifecycle States), createAsyncThunk steps in to automatically send out actions based on these states. These states within thunks help us manage different scenarios in our application:

  1. Pending: When the asynchronous task (like a data fetch) is ongoing. We can use this state to show a loading spinner or a skeleton screen.
  2. Fulfilled: Once the task is completed successfully. At this point, we can update our UI with the fetched data.
  3. Rejected: If there’s an error during the task. We can leverage this state to show error messages or fallback UI components.

By understanding and harnessing these states, we can build a more responsive and user-friendly application.

Check out the next diagram for a better visual understanding:

From the diagram, it’s clear that our state has everything we need to update the user interface. This means users will always be updated about what’s happening in the app.

Hands-On Guide to Redux Thunks

Let’s dive deeper into our previous app, bringing thunks into the mix! If you missed the initial setup, check out our prior article: Mastering Redux Toolkit — Part 1. For a quick start, grab the boilerplate code from our GitHub repository 📁.”

In this example, we won’t build an API using a backend. Instead, we’re keeping things simple and focusing on RTK thunks. We’ll use JSON Server, a tool that gives us a full fake REST API without any coding. Want more on JSON Server? Check it out [here].

Let’start coding!

  1. Install JSON Server
npm install -g json-server

2. Create a db.json file in the root folder with some data

{
"movies": [
{
"id": 1,
"title": "Vomito Soluta Trucido",
"description": "Suscipit ullus tondeo.",
"imageUrl": "https://picsum.photos/seed/gOdQQ/640/480"
},
{
"id": 2,
"title": "Tremo Cohaero Amiculum",
"description": "Cotidie cupio corpus compono aggero.",
"imageUrl": "https://picsum.photos/seed/w4v7WauzbU/640/480"
},
{
"id": 3,
"title": "Verecundia Fugit Velociter",
"description": "Crastinus caterva summopere vinco.",
"imageUrl": "https://loremflickr.com/640/480?lock=5472803241328640"
},
{
"id": 4,
"title": "Adsum Alius Spargo",
"description": "Assumenda vigilo capto angulus uterque vilicus ante aegrotatio demonstro porro.",
"imageUrl": "https://picsum.photos/seed/GE7g6/640/480"
}
],
"songs": [
{
"id": 1,
"title": "Vomito Soluta Trucido",
"description": "Suscipit ullus tondeo.",
"imageUrl": "https://picsum.photos/seed/gOdQQ/640/480"
},
{
"id": 2,
"title": "Tremo Cohaero Amiculum",
"description": "Cotidie cupio corpus compono aggero.",
"imageUrl": "https://picsum.photos/seed/w4v7WauzbU/640/480"
},
{
"id": 3,
"title": "Verecundia Fugit Velociter",
"description": "Crastinus caterva summopere vinco.",
"imageUrl": "https://loremflickr.com/640/480?lock=5472803241328640"
},
{
"id": 4,
"title": "Adsum Alius Spargo",
"description": "Assumenda vigilo capto angulus uterque vilicus ante aegrotatio demonstro porro.",
"imageUrl": "https://picsum.photos/seed/GE7g6/640/480"
}
]
}

Based on the previous db.json file, here are all the default routes that were created from json-server.

GET    /movies
GET /movies/1
POST /movies
PUT /movies/1
PATCH /movies/1
DELETE /movies/1
GET /songs
GET /songs/1
POST /songs
PUT /songs/1
PATCH /songs/1
DELETE /songs/1

3. Start JSON Server

json-server --watch db.json --port 3005

If the server starts successfully on port 3005, you should observe an output similar to the following:

4. Create src/store/thunks folder

5. Create src/store/thunks/moviesThunk.tsx & src/store/thunks/songsThunk.tsx & src/store/thunks/resetThunk.tsx which actually a combined thunk that will be used for our reset functionality.

//src/store/thunks/moviesThunk.tsx
import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
import { Movie } from "../../models";

// Add a new movie
export const addMovie = createAsyncThunk(
'movies/addMovie',
async (movie:Movie) => {
const response = await axios.post('http://localhost:3005/movies', movie);
return response.data;
}
);

// Delete a movie by ID
export const deleteMovie = createAsyncThunk(
'movies/deleteMovie',
async (movieId) => {
await axios.delete(`http://localhost:3005/movies/${movieId}`);
return movieId;
}
);

// Delete all movies
export const deleteAllMovies = createAsyncThunk(
'movies/deleteAllMovies',
async () => {
const response = await axios.get('http://localhost:3005/movies');
const movies = response.data;

// Send delete requests for each movie
const deletePromises = movies.map((movie: Movie) => axios.delete(`http://localhost:3005/movies/${movie.id}`));

// Wait for all delete requests to complete
await Promise.all(deletePromises);
}
);

//src/store/thunks/songsThunk.tsx
import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
import { Song } from "../../models";

// Add a new song
export const addSong = createAsyncThunk(
'songs/addSong',
async (song:Song) => {
const response = await axios.post('http://localhost:3005/songs', song);
return response.data;
}
);

// Delete a song by ID
export const deleteSong = createAsyncThunk(
'songs/deleteSong',
async (songId) => {
await axios.delete(`http://localhost:3005/songs/${songId}`);
return songId;
}
);


// Delete all songs
export const deleteAllSongs = createAsyncThunk(
'movies/deleteAllSongs',
async () => {
const response = await axios.get('http://localhost:3005/songs');
const movies = response.data;

// Send delete requests for each song
const deletePromises = movies.map((song: Song) => axios.delete(`http://localhost:3005/songs/${song.id}`));

// Wait for all delete requests to complete
await Promise.all(deletePromises);
}
);
import { createAsyncThunk } from "@reduxjs/toolkit";
import { deleteAllMovies } from "./moviesThunk";
import { deleteAllSongs } from "./songsThunk";

export const resetAllData = createAsyncThunk(
'reset/resetAllData',
async (_, { dispatch }) => {
await dispatch(deleteAllMovies());
await dispatch(deleteAllSongs());
}
);

From the above code, we can see that in each thunk we have two parts:

Base type: that describes the purpose of the request. Using this base tyoe createAsyncThunk auto-generate the following actions types:

  • movies/fetchMovies/pending
  • movies/fetchMovies/fulfilled
  • movies/fetchMovies/pending

for each base type.

Request: In the thunk, we make the request, and return the data that you want to use in the reducer.

6. Export all these thunks from the src/store/index.tsx file and also update and extend the state for each slice to contain not only the data but also isLoading and error properties.

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


export type SongsState = {
isLoading:boolean;
data: Song[];
error: SerializedError | null;
};

export type MovieState =
{
isLoading:boolean;
data: Movie[];
error: SerializedError | null;
}

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

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

export { store };

export {addMovie,deleteMovie} from './thunks/moviesThunk.tsx';
export {addSong,deleteSong} from './thunks/songsThunk.tsx';
export {resetAllData} from './thunks/resetThunk.tsx';

7. In each of our slices, add extraReducers, watching for the action types made by the above thunks.

//src/store/slices/moviesSlice.tsx
import { Action, ActionReducerMapBuilder, createSlice } from "@reduxjs/toolkit";
import {
MovieState,
addMovie,
deleteMovie,
fetchMovies,
resetAllData,
} from "..";
import { Movie } from "../../models";

const initialMoviesState: MovieState = {
data: [],
isLoading: false,
error: null,
};

const moviesSlice = createSlice({
name: "movie",
initialState: initialMoviesState,
reducers: {},
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
extraReducers(builder: ActionReducerMapBuilder<any>) {
//Add movie
builder.addCase(addMovie.pending, (state: MovieState) => {
state.isLoading = true;
});

builder.addCase(addMovie.fulfilled, (state: MovieState, action) => {
state.isLoading = false;
state.data.push(action.payload);
});
builder.addCase(addMovie.rejected, (state: MovieState, action) => {
state.isLoading = false;
state.error = action.error;
});

//Delete movie
builder.addCase(deleteMovie.pending, (state: MovieState) => {
state.isLoading = true;
});

builder.addCase(deleteMovie.fulfilled, (state: MovieState, action) => {
state.isLoading = false;
state.data = state.data.filter((movie: Movie) => {
return movie.id !== action.payload;
});
});

builder.addCase(deleteMovie.rejected, (state: MovieState, action) => {
state.isLoading = false;
state.error = action.error;
});
builder.addCase(resetAllData.fulfilled, () => {
return initialMoviesState;
});
},
});

export const moviesReducer = moviesSlice.reducer;
//src/store/slices/songsSlice.tsx
import {
ActionReducerMapBuilder,
createSlice,
} from "@reduxjs/toolkit";
import { SongsState, addSong, deleteSong, fetchSongs, reset } from "..";
import { Song } from "../../models";

const initialSongsState:SongsState = {
data:[],
isLoading:false,
error: null
}

const songsSlice = createSlice({
name: "song",
initialState: initialSongsState,
reducers: {
},

// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
extraReducers(builder: ActionReducerMapBuilder<any>) {
//Add song
builder.addCase(addSong.pending,(state)=>{
state.isLoading=true;
});

builder.addCase(addSong.fulfilled,(state,action)=>{
state.isLoading=false;
state.data.push(action.payload);
});
builder.addCase(addSong.rejected,(state,action)=>{
state.isLoading=false;
state.error = action.error;
});

//Delete song
builder.addCase(deleteSong.pending,(state)=>{
state.isLoading=true;
});

builder.addCase(deleteSong.fulfilled,(state,action)=>{
state.isLoading=false;
state.data= state.data.filter((song:Song)=>{
return song.id!==(action.payload as unknown as Song).id;
})
});
builder.addCase(deleteSong.rejected,(state,action)=>{
state.isLoading=false;
state.error = action.error;
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
builder.addCase(reset.type, (_state: any) => {
return [];
});
},
});

export const songsReducer = songsSlice.reducer;

8. Import and dispatch a thunk function in our components:

//src/components/MoviesPlaylist.tsx
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/index.tsx";
import { useDispatch, } from "react-redux";
import { createRandomMovie } from "../utils.tsx/index.tsx";

import LoadingSkeleton from "./LoadingSkeleton.tsx";
import { Avatar, Button, Card, CardContent, CardHeader, CardMedia, Fade, Grid, IconButton, Menu, MenuItem, Stack, Typography } from "@mui/material";
import { SerializedError } from "@reduxjs/toolkit";
import { addMovie, deleteMovie } from "../store/index.tsx";

interface MoviesPlaylistProps{
isLoading: boolean;
data: Movie[];
error: SerializedError | null;
}

function MoviesPlaylist({
data,
isLoading: isLoadingMovies,
error: loadingMoviesError
}:MoviesPlaylistProps) {
const [selectedMovie, setSelectedMovie] = useState<Movie | null>(null);
const dispatch = useDispatch();

const handleMovieAdd = (movie: Movie) => {
dispatch(addMovie(movie));
};

const handleMovieRemove = () => {
if (selectedMovie) {
dispatch(deleteMovie(selectedMovie.id));
}
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 = data.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>
);
});

if (isLoadingMovies) {
return <LoadingSkeleton />;
}

if (loadingMoviesError) {
return <div>Error fetching data</div>;
}

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 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/index.tsx";
import { createRandomSong } from "../utils.tsx/index.tsx";
import LoadingSkeleton from "./LoadingSkeleton.tsx";
import { SerializedError } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import { addSong, deleteSong } from "../store/index.tsx";

interface SongsPlaylistProps{
isLoading: boolean;
data: Song[];
error:SerializedError | null;
}

function SongsPlaylist({
data,
isLoading: isLoadingSongs,
error: loadingSongsError,
}: SongsPlaylistProps) {
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const dispatch = useDispatch();

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

const handleSongRemove = () => {
if (selectedSong) {
dispatch(deleteSong(selectedSong.id));
}
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);
};

if (isLoadingSongs) {
return <LoadingSkeleton />;
}

if (loadingSongsError) {
return <div>Error fetching data</div>;
}

const renderedSongs = data.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}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
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 SongsPlaylist;
//src/App.tsx
import { Button, Divider } from "@mui/material";
import MoviePlaylist from "./components/MoviesPlaylist";
import SongsPlaylist from "./components/SongsPlaylist";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { useDispatch, useSelector } from "react-redux";
import { RootState, resetAllData } from "./store";

export default function App() {
const dispatch = useDispatch();
const handleResetClick = () => {
dispatch(resetAllData())
};

// Use selectors to get data in the App component
const {
isLoading: moviesLoading,
error: moviesError,
data: moviesData,
} = useSelector((state: RootState) => state.movies);

const {
isLoading: songsLoading,
error: songsError,
data: songsData,
} = useSelector((state: RootState) => state.songs);

return (
<>
<Button
onClick={() => handleResetClick()}
variant="contained"
endIcon={<RestartAltIcon />}
sx={{ mb: 2 }}
>
{" "}
Reset Both Playlists
</Button>
<Divider />
<MoviePlaylist data={moviesData} isLoading={moviesLoading} error={moviesError}/>
<Divider />
<SongsPlaylist data={songsData} isLoading={songsLoading} error={songsError}/>
</>
);
}

In our current setup, we’ve incorporated a skeleton loading screen and a basic error display. However, there’s an inefficiency in how we’ve structured our loading state. Currently, when we initiate the process of adding a new movie, the isLoading flag in our MovieState is set to true, which triggers the skeleton loader for the entire movie list. This approach can be misleading for the user because the entire list appears to be loading, even though we're only waiting for a single movie to be added. For optimal user experience, it's essential to show loaders or skeletons contextually, indicating to the user precisely what is being processed. Let’s visualize it for a better understanding:

WRONG WAY

RIGHT WAY

To refine our UI feedback, it’s essential we adopt a more granular approach to visualization. One practical solution involves utilizing local component state, which helps pinpoint the exact action being dispatched at any moment. Another viable method is to log each request’s ID directly within our Redux store, illustrated as:

requests: [{id: requestId, status:'rejected', error: error}]

This concept aligns closely with the mechanism used by Redux Toolkit (RTK) query, a topic we’ll explore further in an upcoming article. In our current context, we’re leaning towards the simplicity of managing state at the component level. Taking advantage of the promise that dispatch yields, we can seamlessly tailor our UI components, such as loaders or notifications, based on the action's progression. This dynamic approach ensures users are consistently informed about the app's operations, leading to a more engaging user experience. You can witness this strategy in action in the subsequent code snippets:

//src/components/MoviesPlaylist.tsx
import {
Avatar,
Card,
CardContent,
CardHeader,
CardMedia,
Fade,
Grid,
IconButton,
Menu,
MenuItem,
Stack,
Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { useCallback, useState } from "react";
import DeleteIcon from "@mui/icons-material/Delete";
import MovieIcon from "@mui/icons-material/Movie";
import { Movie } from "../models/index.tsx";
import { createRandomMovie } from "../utils.tsx/index.tsx";
import { addMovie, deleteMovie } from "../store/index.tsx";
import { useDispatch } from "react-redux";
import LoadingSkeleton from "./LoadingSkeleton.tsx";
import LoadingButton from "@mui/lab/LoadingButton";
import { toast } from "react-toastify";
import { SerializedError } from "@reduxjs/toolkit";

interface MoviesPlaylistProps {
isLoading: boolean;
data: Movie[];
error: SerializedError | null;
}

function MoviesPlaylist({
data,
isLoading: isLoadingMovies,
error: loadingMoviesError,
}: MoviesPlaylistProps) {
const [isAddingMovie, setIsAddingMovie] = useState(false);

const [selectedMovie, setSelectedMovie] = useState<Movie | null>(null);
const dispatch = useDispatch();

const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);

const handleMovieAdd = useCallback(
(movie: Movie) => {
setIsAddingMovie(true);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dispatch(addMovie(movie))
.unwrap()
.then(() => {
toast.success("Movie added succesfully!", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
});
})
.catch(() => {
toast.error(
"Oops! There was an issue adding your movie. Please try again.",
{
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
}
);
})
.finally(() => setIsAddingMovie(false));
},
[dispatch]
);

const handleMovieRemove = useCallback(() => {
if (selectedMovie) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dispatch(deleteMovie(selectedMovie.id))
.unwrap()
.then(() => {
//Show success toast notification
toast.success("Movie deleted succesfully!", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
});
})
.catch(() => {
toast.error(
"Oops! There was an issue deleting your movie. Please try again.",
{
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
}
);
});
}
handleClose();
}, [dispatch, handleClose, selectedMovie]);

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

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

if (!isAddingMovie && isLoadingMovies) {
return <LoadingSkeleton />;
}

if (loadingMoviesError) {
return <div>Error fetching data</div>;
}

const renderedMovies = data.map((movie) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={movie.id}>
<Card sx={{ maxWidth: 345, height: 365 }}>
<CardHeader
avatar={
<Avatar sx={{ bgcolor: "red" }} aria-label="recipe">
<MovieIcon />
</Avatar>
}
action={
<IconButton
aria-label="settings"
onClick={(e) => handleClick(e, movie)}
>
<MoreVertIcon />
</IconButton>
}
title={movie.title}
subheader={movie.description}
/>
<CardMedia
sx={{ height: 140 }}
image={movie.imageUrl}
title={movie.title}
/>
<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",
}}
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>
<LoadingButton
onClick={() => handleMovieAdd(createRandomMovie())}
variant="contained"
startIcon={<AddIcon />}
sx={{ maxHeight: "42px", whiteSpace: "nowrap" }}
loading={isAddingMovie}
>
{" "}
Add Movie
</LoadingButton>
</Stack>
<Grid container spacing={2} style={{ padding: "48px 0px" }}>
{renderedMovies}
</Grid>
</div>
);
}

export default MoviesPlaylist;
//src/components/SongsPlaylist.tsx
import {
Avatar,
Card,
CardContent,
CardHeader,
CardMedia,
Fade,
Grid,
IconButton,
Menu,
MenuItem,
Stack,
Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { useCallback, useState } from "react";
import DeleteIcon from "@mui/icons-material/Delete";
import LibraryMusicIcon from "@mui/icons-material/LibraryMusic";
import { Song } from "../models/index.tsx";
import { createRandomSong } from "../utils.tsx/index.tsx";
import { addSong, deleteSong } from "../store/index.tsx";
import { useDispatch } from "react-redux";
import LoadingSkeleton from "./LoadingSkeleton.tsx";
import LoadingButton from "@mui/lab/LoadingButton";
import { toast } from "react-toastify";
import { SerializedError } from "@reduxjs/toolkit";


interface SongsPlaylistProps{
isLoading: boolean;
data: Song[];
error: SerializedError | null;
}

function SongsPlaylist({data,isLoading:isLoadingSongs,error:loadingSongsError}:SongsPlaylistProps) {
const [isAddingSong, setIsAddingSong] = useState(false);

const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const dispatch = useDispatch();

const handleClose = useCallback(() => {
setAnchorEl(null);
},[]);

const handleSongAdd = useCallback((song: Song) => {
setIsAddingSong(true);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dispatch(addSong(song))
.unwrap()
.then(() => {
toast.success("Song added succesfully!", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
});
})
.catch(() => {
toast.error(
"Oops! There was an issue adding your song. Please try again.",
{
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
}
);

})
.finally(() => setIsAddingSong(false));
},[dispatch]);

const handleSongRemove =useCallback(() => {
if (selectedSong) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dispatch(deleteSong(selectedSong.id))
.unwrap()
.then(() => {
toast.success("Song deleted succesfully!", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
});
})
.catch(() => {
toast.error(
"Oops! There was an issue deleting your song. Please try again.",
{
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
}
);

});
}
handleClose();
},[dispatch, handleClose, selectedSong]);

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

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

if (!isAddingSong && isLoadingSongs) {
return <LoadingSkeleton />;
}

if (loadingSongsError) {
return <div>Error fetching data</div>;
}

const renderedSongs = data.map((song) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={song.id}>
<Card sx={{ maxWidth: 345, height: 365 }}>
<CardHeader
avatar={
<Avatar sx={{ bgcolor: "red" }} aria-label="recipe">
<LibraryMusicIcon />
</Avatar>
}
action={
<IconButton
aria-label="settings"
onClick={(e) => handleClick(e, song)}
>
<MoreVertIcon />
</IconButton>
}
/>
<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}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
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>
<LoadingButton
onClick={() => handleSongAdd(createRandomSong())}
variant="contained"
startIcon={<AddIcon />}
sx={{ maxHeight: "42px", whiteSpace: "nowrap" }}
loading={isAddingSong}
>
{" "}
Add Song
</LoadingButton>
</Stack>
<Grid container spacing={2} style={{ padding: "48px 0px" }}>
{renderedSongs}
</Grid>
</div>
);
}

export default SongsPlaylist;
//src/App.tsx
import { Button, Divider } from "@mui/material";
import MoviePlaylist from "./components/MoviesPlaylist";
import SongsPlaylist from "./components/SongsPlaylist";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { useDispatch, useSelector } from "react-redux";
import { resetAllData } from "./store/thunks/resetThunk";
import { useCallback, useState } from "react";
import { toast } from "react-toastify";
import { RootState } from "./store";

export default function App() {
const dispatch = useDispatch();

const handleResetClick = useCallback(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
dispatch(resetAllData())
.unwrap()
.then(() => {
toast.success("Playlists reseted succesfully!", {
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
});
})
.catch(() => {
toast.error(
"Oops! There was an issue while reseting your playlists. Please try again.",
{
position: "top-right",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
theme: "light",
}
);
})
}, [dispatch]);

// Use selectors to get data in the App component
const {
isLoading: moviesLoading,
error: moviesError,
data: moviesData,
} = useSelector((state: RootState) => state.movies);
const {
isLoading: songsLoading,
error: songsError,
data: songsData,
} = useSelector((state: RootState) => state.songs);

return (
<>
<Button
onClick={() => handleResetClick()}
variant="contained"
endIcon={<RestartAltIcon />}
sx={{ mb: 2 }}
>
{" "}
Reset Both Playlists
</Button>
<Divider />
<MoviePlaylist
data={moviesData}
isLoading={moviesLoading}
error={moviesError}
/>
<Divider />
<SongsPlaylist
data={songsData}
isLoading={songsLoading}
error={songsError}
/>
</>
);
}

The Result

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

Conclusion

  • 📩 Notifications: Instant feedback ensures users always know what’s happening, enhancing the UX.
  • 🌉 Thunks: Bridge the gap between Redux and asynchronous tasks, making complex operations smooth and predictable.
  • 🔄 Loaders & UI Feedback: Provide clear visual cues during data operations, enhancing the user’s experience.
  • 🤝 Unified Experience: Our enhancements, paired with Redux and thunks, create a cohesive and user-friendly environment.

In short, our updates make the app more communicative, efficient, and user-focused.

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

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

Thanks for reading! 🙌 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!

--

--