Knowledge Hub
Redux is a popular state management library for JavaScript applications. State management is very important in modern web applications. It controls what information is shown on the screen as a user interacts with the app. Basically, it keeps track of everything that is happening in the app and decides what to show to the user.
Good state management is essential for creating web applications that work well and provide a great user experience. Without it, things can become confusing and messy, and the user might not know what is going on. By managing the state of the application properly, developers can ensure that the user always sees up-to-date information and that everything runs smoothly. It makes the app more reliable and easier to use. In short, state management is one of the vital aspects of creating a successful web application. In order to effectively manage the state of a web application, developers use various techniques and tools. One common method is to use a state management library such as Redux.
Redux helps manage the state of an application in a more organized and predictable way. It provides a predictable state container for JavaScript applications. This container holds all application states in a central location, making it easy to manage and update.
In this tutorial, you will learn how to create a frontend blog application with various features, including navigation, post creation, editing, deletion, upvoting and downvoting, and adding posts to favorites using React and the latest version of Redux Toolkit.
You can view the finished application by following this link: https://modern-react-redux-blog.vercel.app, while the GitHub repository containing the source code can be found at: https://github.com/bejamas/react-redux-blog
In order to proceed with this tutorial, there are specific tools that you should be familiar with. Firstly, you should have a basic understanding of HTML and CSS. Secondly, it’s important to be familiar with the syntax and features of ES6. Additionally, having knowledge of key terms related to React such as JSX, State, Function Components, Props, and Hooks is essential. Lastly, it would be helpful to have experience with Type Annotations in TypeScript, as TypeScript is the language that will be used in this project. By having this foundational knowledge, you will be able to better understand and implement the concepts presented in the tutorial.
We will be using Vite to bootstrap this project. Vitejs is an ultra-fast and modern web development build tool and development server. It is built on top of modern web technologies such as ES Modules and leverages browser native support for ES Modules to enable lightning-fast development and build times.
Create a new folder called react-redux-blog
and run the following command to setup the project with Vite:
npm create vite@latest
You’ll be asked for the name of your project, simply type: ./
to use the name of the folder (react-redux-blog)
that was created previously.
Next, you will be asked to “Select a framework”, use the arrow key to navigate to the React
option, and press the Enter key.
Finally, you will be asked to “Select a variant”, select TypeScript
, and press the Enter key.
Execute the command below to install React and all other dependencies that have been configured with Vite in the package.json
file:
npm install
Once the installation is complete, you can run the following command to start your development server:
npm run dev
Now launch your browser and navigate to the following link: http://localhost:5174/.
You’ll see a page similar with a basic counter app that looks like this:
Redux core is the original and old library for state management with Redux. It provides the core concepts such as Store, Actions, Reducers, and Middleware, but it requires more boilerplate code to set up and configure. Developers need to manually handle things like action types, action creators, and reducers, which can result in more code and potential errors.
Redux Toolkit (Also referred to as RTK), on the other hand is the new version of Redux and the officially recommended approach for writing Redux logic. It automatically generates action creator functions for each reducer and generates action type strings internally based on the reducer’s names. Redux Toolkit offers excellent TS support, with APIs designed to give you excellent type safety and minimize the number of types you have to define in your code.
Moreover, with RTK, you can write mutating logic without the risk of actual mutation. Its can help avoid unexpected behavior in your application. We will delve more into this later in the tutorial.
RTK will be used for building the application in this tutorial.
The Redux Toolkit is composed of the Redux core, along with other essential packages like Reselect and Redux Thunk, which are deemed vital for developing Redux applications.
React Redux offers bindings that enable React components to communicate with the Redux store.
Run the following command to install both of them:
npm install @reduxjs/toolkit react-redux
A store is an object that holds the global state of our application. To create the store for this app, navigate to the src
directory and create a new folder called app
, then create a new file called store.ts
inside the app
directory.
Open the store.ts
file and add the following code to it:
import { configureStore } from '@reduxjs/toolkit';
export const store = configureStore({
reducer: {},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
Above, we defined a Redux store by calling the configureStore
function and passing an empty reducer
object as an argument. The store is saved into the store
variable, which is exported for later use.
Next, we will use the Provider
component from the react-redux
library to connect the React component to the store
object that was exported from the previous code.
Open the main.ts
file and update it with the following code:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { Provider } from 'react-redux';
import { store } from './app/store';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
In the code above, we used the Provider
component to connect our app to the store
so that all the components can access it within the application.
In this part, we will create all the components that make up the app we are building. Let’s start by replacing the entire content of the src/index.css
file with the following code:
body {
margin: 0;
background-color: #f4f9fd;
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.main-container {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
}
nav {
width: 90%;
border-radius: 10px;
background-color: #fff;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
margin: 20px auto;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
overflow-x: auto;
}
.right-nav-section,
.left-nav-section {
display: flex;
align-items: center;
}
.left-nav-section {
margin-right: 20px;
}
.logo {
display: flex;
flex-direction: column;
padding: 10px;
}
.logo h1,
.logo h2 {
margin: 0;
font-size: 15px;
}
.logo h1 {
font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif;
margin-right: 10px;
}
.logo h2 {
font-weight: 100;
}
.divider {
font-size: 20px;
margin: 0;
font-weight: 100;
color: rgba(0, 0, 0, 0);
border-right: 1px solid rgba(0, 0, 0, 0.321);
}
.nav-item {
margin: 0 0 0 20px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.5);
cursor: pointer;
display: flex;
align-items: center;
}
.nav-item span {
margin-right: 5px;
}
.add-icon {
padding: 0 4px;
border-radius: 50%;
margin-right: 5px;
color: white;
background-color: red;
font-weight: 700;
font-size: 15px;
}
.active {
color: #2c48f3;
}
.card-container {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.card,
.no-posts-card {
width: 250px;
height: 250px;
display: flex;
flex-direction: column;
text-align: left;
padding: 15px;
justify-content: space-between;
background-color: #fff;
border-radius: 10px;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
margin: 10px;
}
.no-posts-card {
justify-content: center;
align-items: center;
text-align: center;
}
.no-posts-card button {
padding: 10px 20px;
color: #fff;
background-color: #2c48f3;
border: none;
font-weight: 600;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
cursor: pointer;
}
.no-posts-card h1 {
font-size: 17px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.edit-icon {
font-size: 16px;
color: rgba(0, 0, 0, 0.801);
cursor: pointer;
padding: 0 0;
border: 3px double rgba(0, 0, 0, 0.788);
font-weight: bold;
box-shadow: rgba(47, 0, 255, 0.55) 0 2px 5px -1px, rgba(0, 0, 0, 0.5) 0 1px 3px -1px;
}
.delete-icon {
margin: 0;
background-color: #2c48f3;
border-radius: 50%;
height: 22px;
width: 22px;
text-align: center;
font-size: 17px;
color: #fff;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
cursor: pointer;
}
.card-body {
overflow: hidden;
padding: 10px 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.card-bottom {
display: flex;
justify-content: space-between;
align-items: center;
}
.favorite {
font-size: 30px;
color: #2600ff;
cursor: pointer;
text-shadow: 1px 1px 3px #2c48f3;
}
.rating-section {
display: flex;
align-items: center;
}
.upvote,
.downvote {
color: #fff;
font-size: 22px;
background-color: #2c48f3;
padding: 0 15px 5px 15px;
margin: 0;
border-radius: 5px;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
cursor: pointer;
text-align: center;
font-weight: 900;
}
.vote-counter {
font-size: 15px;
padding: 5px;
font-weight: 600;
}
.card-title,
.card-summary {
margin: 0;
cursor: pointer;
}
.card-title {
color: rgba(0, 0, 0, 0.7);
}
.card-summary {
font-size: 15px;
font-weight: 200;
color: rgba(0, 0, 0, 0.6);
margin-top: 10px;
font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
}
.view-post-container {
width: 80%;
min-height: 80vh;
margin: 0 auto;
background-color: #fff;
border-radius: 10px;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
margin: 10px;
padding: 40px;
}
.view-post-title {
font-size: 30px;
text-align: center;
text-transform: capitalize;
}
.view-post-content {
font-size: 25px;
text-align: left;
font-weight: 100;
line-height: 40px;
font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
}
.form-card {
width: 60%;
height: 400px;
text-align: center;
padding: 40px;
background-color: #fff;
border-radius: 10px;
box-shadow: rgba(0, 0, 255, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
margin: 10px auto;
display: flex;
flex-direction: column;
align-items: center;
}
.title-input {
width: 100%;
font-size: 20px;
padding: 10px 10px;
outline: none;
}
.content-input {
width: 100%;
height: 200px;
margin-top: 10px;
font-size: 20px;
font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
outline: none;
padding: 10px 10px;
}
.post-button {
width: 100px;
margin-top: 10px;
font-size: 20px;
background-color: #2c48f3;
padding: 10px;
color: #fff;
border: none;
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
}
footer {
width: 50%;
border-radius: 10px;
background-color: #000;
box-shadow: rgba(0, 0, 0, 0.25) 0 2px 5px -1px, rgba(66, 4, 236, 0.3) 0 1px 3px -1px;
margin: 20px auto;
padding: 0 10px;
}
footer p {
color: #fff;
text-align: center;
font-size: 14px;
font-weight: 500;
}
@media only screen and (max-width: 450px) {
.title-input {
font-size: 15px;
}
.content-input {
font-size: 15px;
}
}
@media only screen and (max-width: 300px) {
.nav-item {
margin: 0 0 0 15px;
font-size: 10px;
font-weight: 200;
}
.add-icon {
padding: 0 4px;
border-radius: 50%;
margin-right: 2px;
color: white;
font-weight: 700;
font-size: 16px;
}
.left-nav-section {
margin-right: 0;
}
footer {
width: 70%;
}
}
NavigationBar
componentIn the src
directory, create a new folder called features
. Redux recommends organizing the logic of the app into the features
directory. In the feature
directory, create a folder called navigations
. The navigations
folder will contain the components related to the app navigation, as well as a single file that uses the createSlice
API for the navigation feature (navigationSlice
which will be created later).
The navigations folder will house the components responsible for the app’s navigation, along with a single file that uses the createSlice
API for the navigation feature (navigationSlice
, which will be created at a later stage).
Now, we are ready to create the NavigationBar
component. To do this, create a new file named NavigationBar.tsx
inside the features
directory and add the following code to it:
const NavigationBar = () => {
return (
<>
<nav>
<section className="right-nav-section">
<div className="logo">
<h1>Redux</h1>
<h2>Blog</h2>
</div>
<p className="divider">|</p>
<p className="nav-item">Blogs</p>
<p className="nav-item">
<span className="add-icon">+</span>Add New
</p>
</section>
<section className="left-nav-section">
<p className="divider">|</p>
<p className="nav-item">
<span>❤</span>Favorites
</p>
</section>
</nav>
</>
);
};
export default NavigationBar;
We defined a functional component called NavigationBar
that renders a navigation bar for our app, with Blogs and Add New navigation items on the right-hand side and a Favorites navigation item on the left-hand side.
AddNewPost
componentTo add a new post, we will create a new component called AddNewPost
. However, before proceeding with the creation of the component, we must first create a typing file that will be used to define the structure of the new post. Start by creating a directory named typings
in the src
folder, then create a new file named index.ts
. You can then include the code below within the file:
export interface IPost {
id: number,
title: string,
content: string,
voteCount:number,
isFavorite: boolean,
}
We defined a new TypeScript interface called IPost
which has five properties: id
, title
, content
, voteCount
, and isFavorite
. The id
and voteCount
properties are numbers, the title
and content
properties are strings and the isFavorite
property is a boolean.
In the features
directory, create a new folder called posts
. Inside the posts
folder, create a new file called PostInputForm.tsx
, and add the following code to it:
import { FormEventHandler } from "react";
const PostInputForm = (props: {
submitPost: FormEventHandler<HTMLFormElement>;
title: string;
setTitle: (title: string) => void;
content: string;
setContent: (content: string) => void;
}) => {
return (
<>
<form className="form-card" onSubmit={props.submitPost}>
<h1>Create a new Post</h1>
<input
type="text"
className="title-input"
value={props.title}
onChange={(e) => props.setTitle(e.target.value)}
/>
<textarea
className="content-input"
value={props.content}
onChange={(e) => props.setContent(e.target.value)}
></textarea>
<input type="submit" value="Publish" className="post-button" />
</form>
</>
);
};
export default PostInputForm;
We defined a new component called PostInputForm
that renders a form for creating a new post. The form includes an input field for the title, a text area for the content, and a submit button. This component will also be used whenever a post needs to be modified.
Create a new file in the features/posts
directory called AddNewPost.tsx
and then add the following code to it:
import { useState } from "react";
import { IPost } from "../../typings";
import PostInputForm from "./PostInputForm";
const AddNewPost = () => {
const [title, setTitle] = useState("Blog Title");
const [content, setContent] = useState("Blog Details");
const submitPost = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const post: IPost = {
id: Date.now(),
title,
content,
voteCount: 0,
isFavorite: false,
};
console.log(post);
};
return (
<>
<PostInputForm
submitPost={submitPost}
title={title}
content={content}
setTitle={setTitle}
setContent={setContent}
/>
</>
);
};
export default AddNewPost;
We created a component called AddNewPost
that renders a form for creating a new post, using the imported PostInputForm
component that was created previously. The component uses the useState
hook to create two state variables - title
and content
, which are initialized with default values “Blog Title” and “Blog Details”. The component also defines a function called submitPost
which is passed as a prop to the PostInputForm
component.
We will handle the submitPost
function later in this tutorial.
BlogCard
componentEach blog content on the homepage will be rendered in a card. In this part, we will create a new component that will be used to render each post on the homepage. In the features/posts
directory, create a new file called BlogCard.tsx
and add the following code to it:
const BlogCard = (props: { posts: any }) => {
return (
<>
{props.posts.map((post: any) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon">✎</span>
<h1 className="delete-icon">x</h1>
</div>
<section className="card-body">
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
<section>
<span
className="favorite"
>
♥
</span>
</section>
<section className="rating-section">
<span className="upvote">⇧</span>
<span className="vote-counter">0</span>
<span className="downvote">⇩</span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
This component takes in a single prop called posts, which is expected to be an array of post objects, then uses the map function to iterate over the post array and render a card element for each post.
Each card element includes a header section, a body section, and a bottom section. The header section has a pencil icon to edit a post and a cross icon to delete a post. The body section includes the post’s title, truncated to 150 characters with an ellipsis if it’s longer, and the post’s content, truncated to 120 characters with an ellipsis if it’s longer. The bottom section has a heart icon, which will be used to add or remove a post from favorites, and a section for voting, which has an upvote arrow, a vote counter, and a downvote arrow. Each card also has a key prop which is set to the post’s id.
NoPosts
componentThe NoPosts
component will be rendered whenever the post array is empty. In the component features/posts
directory, create a new file called NoPosts.tsx
and add the following code to it:
const NoPosts = () => {
return (
<>
<section className="no-posts-card">
<h1>You have not created any blog posts.</h1>
<button>Add New</button>
</section>
</>
);
};
export default NoPosts;
This component will render a card that indicates that the user has not created any blog posts. The component has a heading tag that says “You have not created any blog posts” and a button that says “Add New”. The button will be handled later in this tutorial.
AllPosts
componentThis component will be used to render all the user posts. Create a new file called AllPosts.tsx
in the features/posts
directory, and add the following code to it:
import BlogCard from "./BlogCard";
import NoPosts from "./NoPosts";
const AllPosts = () => {
const allPosts: any = [];
if (!allPosts.length) {
return <NoPosts />;
}
return (
<>
<BlogCard posts={allPosts} />
</>
);
};
export default AllPosts;
We created a new component called AllPosts
that will be used to render a list of all blog posts. The component imports BlogCard
and NoPosts
components. If the blog post array is empty, it renders the NoPosts
component. Otherwise, it returns the BlogCard
component.
EditPost
componentThe EditPost
component will be rendered anytime an existing post needs to be modified. In the features/posts
directory, create a new file called EditPost.tsx
and add the following code:
import { useState } from "react";
import { IPost } from "../../typings";
import PostInputForm from "./PostInputForm";
const EditPost = () => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const updatePost = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const post: IPost = {
id: Date.now(),
title,
content,
voteCount: 0,
isFavorite: false,
};
console.log(post);
};
return (
<>
<PostInputForm
submitPost={updatePost}
title={title}
content={content}
setTitle={setTitle}
setContent={setContent}
/>
</>
);
};
export default EditPost;
We created a new component called EditPost
. For now, we initialize the two state variables - title
and content
, with empty strings as their initial values. We also define a function called updatePost
which is triggered whenever the form is submitted. We will handle this function later in this tutorial.
NoFavoritePosts
componentLet’s create another component called NoFavoritePosts
. This component will be rendered on the favorites page if no post has been added to the favorites list.
Create another file in the features/posts
directory called NoFavoritePosts.tsx
and add the following code to it:
const NoFavoritePosts = () => {
return (
<>
<section className="no-posts-card">
<h1>You haven't marked any blog posts as favorites.</h1>
<button>Add a post to your favorites</button>
</section>
</>
);
};
export default NoFavoritePosts;
FavoritePosts
componentThe FavoritePosts
component will be used to display every post that has been marked as a favorite. Create a new file in the features/posts
directory called FavoritePosts.tsx
and add the following code to it:
import BlogCard from "./BlogCard";
import NoFavoritePosts from "./NoFavoritePosts";
const FavoritePosts = () => {
const favoritePosts: any[] = [];
if (!favoritePosts.length) {
return <NoFavoritePosts />;
}
return (
<>
<BlogCard posts={favoritePosts} />
</>
);
};
export default FavoritePosts;
ViewPostDetails
componentThe ViewPostDetails
component will be used to view the details of a selected blog post. To create the ViewPostDetails
component, we will create a new file in the features/posts
directory called ViewPostDetails.tsx
and add the following code to it:
const ViewPostDetails = () => {
const post = { title: "", content: "" };
return (
<>
<section className="view-post-container">
<h1 className="view-post-title">{post.title}</h1>
<p className="view-post-content">{post.content}</p>
</section>
</>
);
};
export default ViewPostDetails;
Footer
ComponentIn the src/fearures/posts
file, create a new component called Footer.tsx
open it, and add the following code to it:
const Footer = () => {
return (
<>
<footer>
<p>
Made with ❤ by
<a href="<https://github.com/tope-olajide>">Temitope.js</a>
</p>
</footer>
</>
);
};
export default Footer;
Let’s gain some understanding of what a slice is before we begin creating the necessary slices for this application.
Slices is a powerful feature of Redux that make it easier to manage complex states, write clean and modular code, and avoid common pitfalls of state management. It is created using the createSlice
function provided by Redux Toolkit, which generates a reducer function, actions, and action creators for the slice.
Slices help simplify the process of writing Redux logic by allowing you to work with smaller parts of your state tree instead of having to manage the entire state at once. It can make your code more modular, easier to test, and reduce the risk of errors caused by unintentional state mutations. Each slice has a unique name and a set of reducers
that specifies how the slice should be updated when an action is dispatched. Actions generated by a slice are automatically assigned a type that includes the slice’s name, making it easier to track which slice is responsible for a particular action.
Before creating the navigation slice, let’s create a new type called INavigationState
below the IPost
interface in the typings/index.ts
file:
export interface INavigationState {
currentComponent:
| "AllPostsComponent"
| "AddNewPostComponent"
| "FavoriteComponent"
| "EditPostComponent"
| "ViewPostComponent";
}
We defined a new TypeScript interface called INavigationState
that defines a property called currentComponent
. The currentComponent
property can have one of five possible string values: AllPostsComponent
, AddNewPostComponent
, FavoriteComponent
, EditPostComponent
, or ViewPostComponent
.
After that, we will create a new file in src/features/navigations
called navigationSlice.tsx
and add the following code to it:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { INavigationState } from "../../typings";
const initialState: INavigationState = {
currentComponent: "AllPostsComponent",
};
export const navigationSlice = createSlice({
name: "navigations",
initialState: initialState,
reducers: {
switchToAllPostsComponent: (state) => {
state.currentComponent = "AllPostsComponent";
},
switchToAddNewPostComponent: (state) => {
state.currentComponent = "AddNewPostComponent";
},
switchToFavoriteComponent: (state) => {
state.currentComponent = "FavoriteComponent";
},
switchToEditPostComponent: (state) => {
state.currentComponent = "EditPostComponent";
},
switchToViewPostComponent: (state) => {
state.currentComponent = "ViewPostComponent";
},
},
});
export const {
switchToAllPostsComponent,
switchToAddNewPostComponent,
switchToFavoriteComponent,
switchToEditPostComponent,
switchToViewPostComponent,
} = navigationSlice.actions;
export const selectCurrentComponent = (state: RootState) =>
state.navigation.currentComponent;
export default navigationSlice.reducer;
In the code above, we created a Redux slice that will be used to handle the navigation in our app.
We used the createSlice
function from the “reduxjs/toolkit” library to create the navigationSlice
, which takes in the initial state (i.e., initialState
), an object of reducer functions (i.e., switchToAllPostsComponent
, switchToAddNewPostComponent
, switchToFavoriteComponent
, switchToEditPostComponent
, and switchToViewPostComponent
) and a “slice name” (i.e., navigations
) then we exported the action creators automatically generated by the navigationSlice
function and the selector function (i.e., selectCurrentComponent
) that can be used to access the current component (i.e., currentComponent
) from the global state.
With the createSlice
function in Redux, we can write reducers that appear to mutate the state but actually don’t. It is because createSlice
uses the Immer library to detect changes to a “draft state” and creates a new immutable state based on those changes. So, we get the benefit of writing reducer logic that looks like it’s mutating the state but without the actual mutations that can lead to unexpected behavior in our application.
Open the src/app/store.ts
file, import the navigation reducer exported from the navigationSlice
function, and then pass it to the store
object like this:
import { configureStore } from '@reduxjs/toolkit';
+ import navigationReducer from '../features/navigations/navigationSlice'
export const store = configureStore({
+ reducer: {navigation: navigationReducer},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
useDispatch
and useSelector
hooksThe useDispatch
and useSelector
are two hooks provided by the react-redux
library that enable components in a React application to interact with the global Redux store.
The useDispatch
hook is used to dispatch actions to the Redux store. When an action is dispatched, it triggers a state update in the store, which in turn causes React components that depend on the changed state to re-render.
The useSelector
hook is used to select a part of the state from the Redux store. It takes a function that returns a slice of the Redux store’s state and returns that slice of state to the component that called it.
In the src/apps
directory, create a new file called hooks.ts
, open it, and add the following code:
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
We defined two new hooks that wrap the original useDispatch
and useSelector
hooks. These new hooks are called useAppDispatch
and useAppSelector
. The useAppDispatch
hook returns an instance of the useDispatch
hook with the AppDispatch
type specified as the generic argument. The AppDispatch
type represents the type of the dispatch
function returned by the Redux store.
The useAppSelector
hook is a strongly-typed version of the useSelector
hook that takes the RootState
type as an argument. It ensures that the selected state is of the correct type and avoids type errors.
These new hooks will be used throughout the app instead of the original useDispatch
and useSelector
hooks to provide stronger type safety and to ensure that the correct types are used.
Open the src/features/navigations/NavigationBar.tsx
file and overwrite everything in the NavigationBar
component with the following code:
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import {
selectCurrentComponent,
switchToAllPostsComponent,
switchToAddNewPostComponent,
switchToFavoriteComponent,
} from "./navigationSlice";
const NavigationBar = () => {
const dispatch = useAppDispatch();
const currentComponent = useAppSelector(selectCurrentComponent);
return (
<>
<nav>
<section className="right-nav-section">
<div className="logo" onClick={() => dispatch(switchToAllPostsComponent())}>
<h1>Redux</h1>
<h2>Blog</h2>
</div>
<p className="divider">|</p>
<p
className={
currentComponent === "AllPostsComponent"
? "nav-item active"
: "nav-item"
}
onClick={() => dispatch(switchToAllPostsComponent())}
>
Blogs
</p>
<p
className={
currentComponent === "AddNewPostComponent"
? "nav-item active"
: "nav-item"
}
onClick={() => dispatch(switchToAddNewPostComponent())}
>
<span className="add-icon">+</span>New
</p>
</section>
<section className="left-nav-section">
<p className="divider">|</p>
<p
className={
currentComponent === "FavoriteComponent"
? "nav-item active"
: "nav-item"
}
onClick={() => dispatch(switchToFavoriteComponent())}
>
<span>❤</span>Favorites
</p>
</section>
</nav>
</>
);
};
export default NavigationBar;
We first imported the custom hooks useAppDispatch
and useAppSelector
that we had previously created in the hooks file.
When a navigation link is clicked, the useAppDispatch
function will be used to dispatch an action to the store to change the current component.
The useAppSelector
hook retrieves the current component from the store and assigns it to the currentComponent
variable. Depending on the value of currentComponent
, the color of the current navigation item will be changed to blue. For instance, if currentComponent
is equal to “AllPostsComponent”, the “nav-item active” class will be rendered, otherwise only the “nav-item” class will be rendered.
To view all the components in action, replace the contents of the App.tsx
file with the following code:
import { useAppSelector, useAppDispatch } from "./app/hooks";
import AddNewPost from "./features/posts/AddNewPost";
import AllPosts from "./features/posts/AllPosts";
import EditPost from "./features/posts/EditPost";
import FavoritePosts from "./features/posts/FavoritePosts";
import Footer from "./features/posts/Footer";
import NavigationBar from "./features/navigations/NavigationBar";
import { selectCurrentComponent } from "./features/navigations/navigationSlice";
import ViewPostDetails from "./features/posts/ViewPostDetails";
const App = () => {
const currentComponent = useAppSelector(selectCurrentComponent);
return (
<>
<section className="main-container">
<NavigationBar />
<section className="card-container">
{currentComponent === "AllPostsComponent" ? (
<AllPosts />
) : currentComponent === "EditPostComponent" ? (
<EditPost />
) : currentComponent === "ViewPostComponent" ? (
<ViewPostDetails />
) : currentComponent === "AddNewPostComponent" ? (
<AddNewPost />
) : currentComponent === "FavoriteComponent" ? (
<FavoritePosts />
) : null}
</section>
<Footer />
</section>
</>
);
};
export default App;
We used a series of conditional statements (ternary operators) to render the appropriate component based on the value of currentComponent
. For example, we render the AllPosts
component if currentComponent
is equal to “AllPostsComponent”, EditPost
component if currentComponent
is equal “EditPostComponent” and so on. If none of the conditions are met, we return null. Also, we render NavigationBar
and Footer
components, which are displayed on every screen. The rendered component is wrapped in a <section className="card-container">
element.
A component has been created for accepting new post input, however, the feature to save the post into the state is yet to be implemented.
Let’s create a new file called postSlice.ts
in the src/features/posts
directory and add the following code to it:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
}
const initialState:IAllPosts = { allPosts : [] };
export const postSlice = createSlice({
name: 'all-posts',
initialState: initialState,
reducers: {
saveBlogPost: (state, actions:{payload:IPost}) => {
state.allPosts.push(actions.payload);
}
}
})
export const selectAllPosts = (state: RootState) => state.posts;
export const { saveBlogPost } = postSlice.actions;
export default postSlice.reducer;
In the code above, we defined the initialState
, which is an object with a property allPosts
that is an array of blog posts. Then we create the postSlice
function using the createSlice
function from the @reduxjs/toolkit
library. The name of the postSlice
function is “all-posts”. The postSlice
function has a reducer called saveBlogPost
, which will be dispatched anytime we need to save a new post. The saveBlogPost
reducer takes the state and an action object as its arguments. The action object has a payload
property containing the new post that will be pushed into the state array.
We also create a selector function called selectAllPosts
, which takes the whole state as an argument and returns the posts
property of it. We will call the selectAllPosts
anytime we need to fetch all the posts. Lastly, the slice exports the postSlice.reducer
which is the generated reducer function as the default export.
In the src/app/store.ts
file, import the post reducer, and add it to the store object:
import { configureStore } from '@reduxjs/toolkit';
import navigationReducer from '../features/navigations/navigationSlice'
+ import postReducer from "../features/posts/postSlice";
export const store = configureStore({
reducer: {
navigation: navigationReducer,
+ posts:postReducer
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
We are now ready to start saving a new post.
Open up the AddNewPost
component and update it with the following changes to save a new post:
import { useState } from "react";
import { IPost } from "../../typings";
import PostInputForm from "./PostInputForm";
+import { useAppDispatch } from "../../app/hooks";
+import { saveBlogPost } from "./postSlice";
+import { switchToAllPostsComponent } from "../navigations/navigationSlice";
const AddNewPost = () => {
const [title, setTitle] = useState("Blog Title");
const [content, setContent] = useState("Blog Details");
+ const dispatch = useAppDispatch();
const submitPost = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const post: IPost = {
id: Date.now(),
title,
content,
voteCount: 0,
isFavorite: false,
};
- console.log(post);
+ dispatch(saveBlogPost(post));
+ dispatch(switchToAllPostsComponent());
};
return (
<>
<PostInputForm
submitPost={submitPost}
title={title}
content={content}
setTitle={setTitle}
setContent={setContent}
/>
</>
);
};
export default AddNewPost;
We imported the useAppDispatch
hook, as well as two action creators named saveBlogPost
and switchToAllPostsComponent
from the “postSlice” and “navigationSlice” files.
When the user submits the form, the submitPost
function is called, which prevents the default form submission behavior and creates a new IPost
object using the current state of the input fields. The saveBlogPost
function from the postSlice
slice is then dispatched with the new post as its argument. Finally, the switchToAllPostsComponent
function from the navigationSlice
slice is dispatched to switch the app’s current view to the list of all posts.
Let’s update the NoPost
component to switch to AddNewPost
component whenever the “Add new” button is clicked:
+import { useAppDispatch } from "../../app/hooks";
+import { switchToAddNewPostComponent } from "../navigations/navigationSlice";
const NoPosts = () => {
+ const dispatch = useAppDispatch();
return (
<>
<section className="no-posts-card">
<h1>You have not created any blog posts.</h1>
- <button>Add New</button>
+ <button onClick={() => dispatch(switchToAddNewPostComponent())}>
+ Add New
+ </button>
</section>
</>
);
};
export default NoPosts;
We have implemented the functionality to save new blog posts, but we are currently unable to view them since the “view all posts” feature has not yet been implemented. In the next section, we will update the AllPosts
component to display all the posts.
To retrieve all posts in the app, we will update the AllPosts
component to fetch all the available posts from the store and render them.
Open the AllPosts.tsx
and update it with the following code:
import BlogCard from "./BlogCard";
import NoPosts from "./NoPosts";
+import { useAppSelector } from "../../app/hooks";
+import { selectAllPosts } from "./postSlice";
const AllPosts = () => {
- const allPosts: any = [];
+const {allPosts} = useAppSelector(selectAllPosts);
if (!allPosts.length) {
return <NoPosts />;
}
return (
<>
<BlogCard posts={allPosts} />
</>
);
};
export default AllPosts;
First, we import the BlogCard
and NoPosts
components, as well as the useAppSelector
and selectAllPosts
functions from the postSlice
file.
The component first calls the useAppSelector
hook with the selectAllPosts
function to get the allPosts
array from the Redux store. If the allPosts
array is empty, the component renders the NoPosts
component. Otherwise, it renders the BlogCard
component with the allPosts
array passed in as a prop.
The BlogCard
component will then render each blog post in the allPosts
array as a separate card.
Now we can update the BlogCard
component with the following changes to display each post on a card:
+import { IPost } from "../../typings";
-const BlogCard = (props: { posts: any }) => {
+const BlogCard = (props: { posts: IPost[] }) => {
return (
<>
- {props.posts.map((post: any) => {
+ {props.posts.map((post) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon">✎</span>
<h1 className="delete-icon">x</h1>
</div>
<section className="card-body">
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
<section>
<span
className="favorite"
>
❤
</span>
</section>
<section className="rating-section">
<span className="upvote">⇧</span>
<span className="vote-counter">0</span>
<span className="downvote">⇩</span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
We’ll create two reducer functions, namely upvotePost
and downvotePost
in the postSlice
, which will be used for upvoting and downvoting a post, and the resulting action creators will be exported.
Open the postSlice.tsx
file and update it with the following changes:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
}
const initialState: IAllPosts = { allPosts: [] };
export const postSlice = createSlice({
name: "all-posts",
initialState: initialState,
reducers: {
saveBlogPost: (state, actions: { payload: IPost }) => {
state.allPosts.push(actions.payload);
},
+ upvotePost: (state, actions: { payload: number }) => {
+ const updatedPosts = state.allPosts.map((post) => {
+ if (post.id === actions.payload) {
+ return { ...post, voteCount: post.voteCount + 1 };
+ }
+ return post;
+ });
+ state.allPosts = updatedPosts;
+ },
+ downvotePost: (state, actions: { payload: number }) => {
+ const updatedPosts = state.allPosts.map((post) => {
+ if (post.id === actions.payload) {
+ return { ...post, voteCount: post.voteCount - 1 };
+ }
+ return post;
+ });
+ state.allPosts = updatedPosts;
+ },
},
});
export const selectAllPosts = (state: RootState) => state.posts;
export const {
saveBlogPost,
+ upvotePost,
+ downvotePost,
} = postSlice.actions;
export default postSlice.reducer;
The upvotePost
reducer function takes the current state and a payload containing a post ID and returns a new array where the post with the matching ID has its voteCount
property incremented by 1 using the map method. The downvotePost
reducer, on the other hand, behaves similarly to the upvotePost
reducer, but instead of increasing the voteCount
property, it decreases it by 1 for the selected post.
Note that the state.allPosts
array was directly modified. It is made possible by createSlice
, which allows us to write “mutating” logic in reducers.
We need to modify the BlogCard
component so that when a user clicks on the upward arrow icon, the post is upvoted, and when thy click on the downward arrow icon, the post is downvoted.
Open the BlogCard.tsx
file and update it with the following changes:
import { IPost } from "../../typings";
+import { downvotePost, upvotePost } from "./postSlice";
+import { useAppDispatch } from "../../app/hooks";
const BlogCard = (props: { posts: IPost[] }) => {
+ const dispatch = useAppDispatch();
return (
<>
{props.posts.map((post) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon">✎</span>
<h1 className="delete-icon">x</h1>
</div>
<section className="card-body">
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
<section>
<span
className="favorite"
>
❤
</span>
</section>
<section className="rating-section">
- <span className="upvote">⇧</span>
- <span className="vote-counter">0</span>
- <span className="downvote">⇩</span>
+ <span
+ className="upvote"
+ onClick={() => dispatch(upvotePost(post.id))}
+ >
+ ⇧
+ </span>
+ <span className="vote-counter">{post.voteCount}</span>
+ <span
+ className="downvote"
+ onClick={() => dispatch(downvotePost(post.id))}
+ >
+ ⇩
+ </span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
To enable the addition or removal of a post from favorites, we will create a new reducer function called toggleFavorite
. We will also export the action creator for this function so that it can be used in other parts of the application as needed.
Go to the postSlice.ts
file and update it with the following code:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
}
const initialState: IAllPosts = { allPosts: [] };
export const postSlice = createSlice({
name: "all-posts",
initialState: initialState,
reducers: {
saveBlogPost: (state, actions: { payload: IPost }) => {
state.allPosts.push(actions.payload);
},
upvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount + 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
downvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount - 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
+ toggleFavorite: (state, actions: { payload: number }) => {
+ const updatedPosts = state.allPosts.map((post) => {
+ if (post.id === actions.payload) {
+ return { ...post, isFavorite: !post.isFavorite };
+ }
+ return post;
+ });
+ state.allPosts = updatedPosts;
+ },
},
});
export const selectAllPosts = (state: RootState) => state.posts;
export const {
saveBlogPost,
upvotePost,
downvotePost,
+ toggleFavorite
} = postSlice.actions;
export default postSlice.reducer;
In the code above, we defined a reducer function named toggleFavorite
that toggles the isFavorite
property of a blog post. The function takes the current state and an action containing the ID of the post to toggle. It creates a new array of updated posts by mapping over the existing posts array and toggling the isFavorite
property of the post with the given ID. Finally, the function updates the state’s allPosts
property with the new array of updated posts.
Let’s update the BlogCard
component to call the toggleFavorite
function with the ID of the post anytime a user clicks on the favorite icon on a post.
Open the Blog.ts
file and modify it with the following additions:
import { IPost } from "../../typings";
import { downvotePost,
upvotePost,
+ toggleFavorite
} from "./postSlice";
import { useAppDispatch } from "../../app/hooks";
const BlogCard = (props: { posts: IPost[] }) => {
const dispatch = useAppDispatch();
return (
<>
{props.posts.map((post) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon">✎</span>
<h1 className="delete-icon">x</h1>
</div>
<section className="card-body">
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
- <section>
+ <section onClick={()=>dispatch(toggleFavorite(post.id))}>
- <span className="favorite">♥</span>
+ <span className="favorite">{post.isFavorite ? "♥" : "♡"}</span>
</section>
<section className="rating-section">
{/* <span className="upvote">⇧</span>
<span className="vote-counter">0</span>
<span className="downvote">⇩</span> */}
<span
className="upvote"
onClick={() => dispatch(upvotePost(post.id))}
>
⇧
</span>
<span className="vote-counter">{post.voteCount}</span>
<span
className="downvote"
onClick={() => dispatch(downvotePost(post.id))}
>
⇩
</span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
To display the details of a post, we will create a new object called currentPost
. The logic behind viewing the details of a post is simple. Whenever a user clicks on a post, the post will be saved to the currentPost
object, and the ViewPostDetails
component will be rendered immediately. Once the ViewPostDetails
component is rendered, it will retrieve the post stored in the currentPost
object and display its title and contents.
Open up the postSlice.ts
file and make the following changes to it:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
+ currentPost: IPost;
}
+const defaultCurrentPost: IPost = {
+ id: 0,
+ title: "",
+ content: "",
+ voteCount: 0,
+ isFavorite: false,
+};
const initialState: IAllPosts = {
allPosts: [],
+ currentPost: defaultCurrentPost,
};
export const postSlice = createSlice({
name: "all-posts",
initialState: initialState,
reducers: {
saveBlogPost: (state, actions: { payload: IPost }) => {
state.allPosts.push(actions.payload);
},
upvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount + 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
downvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount - 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
toggleFavorite: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, isFavorite: !post.isFavorite };
}
return post;
});
state.allPosts = updatedPosts;
},
+ setCurrentPost: (state, action: { payload: IPost }) => {
+ state.currentPost = action.payload;
+ return state;
+ },
},
});
export const selectAllPosts = (state: RootState) => state.posts;
export const {
saveBlogPost,
upvotePost,
downvotePost,
toggleFavorite,
+ setCurrentPost,
} = postSlice.actions;
export default postSlice.reducer;
Firstly, we added a new property called currentPost
to the initialState
. Then we defined a reducer function called setCurrentPost
that takes in the current state of the store and an action, which has a payload
property containing the post selected by a user. The reducer function updates the currentPost
property of the state to the post object contained in the action payload.
The reducer function will be used to update the state with the post the user clicked on so it can be displayed in the ViewPostDetails
component.
Now we can update the ViewPostDetails
component to display the post title and contents stored in the currentPost
object once it’s rendered:
+import { useAppSelector } from "../../app/hooks";
+import { selectAllPosts } from "./postSlice";
const ViewPostDetails = () => {
- const post = { title: "", content: "" };
+ const allPost = useAppSelector(selectAllPosts);
return (
<>
<section className="view-post-container">
- <h1 className="view-post-title">{post.title}</h1>
- <p className="view-post-content">{post.content}</p>
+ <h1 className="view-post-title"> {allPost.currentPost.title} </h1>
+ <p className="view-post-content"> {allPost.currentPost.content} </p>
</section>
</>
);
};
export default ViewPostDetails;
In the BlogCard
component, we will create a function called viewDetails
. This function will take in the post that a user clicks on as its parameter. When called, the function will dispatch two actions:
setCurrentPost(post)
: this action sets the currentPost
state in the store to the post passed as a parameter to viewDetails
.switchToViewPostComponent()
: this action sets the currentComponent
state to ViewPostComponent
.The viewDetails
function will then be invoked whenever a post is clicked on.
Open the BlogCard.ts
file and update it with the following additions:
import { IPost } from "../../typings";
import {
downvotePost,
upvotePost,
toggleFavorite,
+ setCurrentPost
} from "./postSlice";
import { useAppDispatch } from "../../app/hooks";
+import { switchToViewPostComponent } from "../navigations/navigationSlice";
const BlogCard = (props: { posts: IPost[] }) => {
const dispatch = useAppDispatch();
+ const viewDetails = (post: IPost) => {
+ dispatch(setCurrentPost(post));
+ dispatch(switchToViewPostComponent());
+ };
return (
<>
{props.posts.map((post) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon">✎</span>
<h1 className="delete-icon">x</h1>
</div>
<section
className="card-body"
+ onClick={() => {
+ viewDetails(post);
+ }}
>
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
<section onClick={() => dispatch(toggleFavorite(post.id))}>
<span className="favorite">{post.isFavorite ? "♥" : "♡"}</span>
</section>
<section className="rating-section">
<span
className="upvote"
onClick={() => dispatch(upvotePost(post.id))}
>
⇧
</span>
<span className="vote-counter">{post.voteCount}</span>
<span
className="downvote"
onClick={() => dispatch(downvotePost(post.id))}
>
⇩
</span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
In order to modify an existing post, a new reducer function called updateBlogPost
will be created. Open the postSlice.ts
file and update it with the following changes:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
currentPost: IPost;
}
const defaultCurrentPost: IPost = {
id: 0,
title: "",
content: "",
voteCount: 0,
isFavorite: false,
};
const initialState: IAllPosts = {
allPosts: [],
currentPost: defaultCurrentPost,
};
export const postSlice = createSlice({
name: "all-posts",
initialState: initialState,
reducers: {
saveBlogPost: (state, actions: { payload: IPost }) => {
state.allPosts.push(actions.payload);
},
upvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount + 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
downvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount - 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
toggleFavorite: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, isFavorite: !post.isFavorite };
}
return post;
});
state.allPosts = updatedPosts;
},
setCurrentPost: (state, action: { payload: IPost }) => {
state.currentPost = action.payload;
return state;
},
updatePost: (
state,
actions: { payload: { id: number; content: string; title: string } }
) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload.id) {
return {
...post,
title: actions.payload.title,
content: actions.payload.content,
};
}
return post;
});
state.allPosts = updatedPosts;
},
+ updateBlogPost: (
+ state,
+ actions: { payload: { id: number; content: string; title: string } }
+ ) => {
+ const updatedPosts = state.allPosts.map((post) => {
+ if (post.id === actions.payload.id) {
+ return {
+ ...post,
+ title: actions.payload.title,
+ content: actions.payload.content,
+ };
+ }
+ return post;
+ });
+ state.allPosts = updatedPosts;
+ },
},
});
export const selectAllPosts = (state: RootState) => state.posts;
export const {
saveBlogPost,
upvotePost,
downvotePost,
toggleFavorite,
setCurrentPost,
+ updateBlogPost,
} = postSlice.actions;
export default postSlice.reducer;
The updateBlogPost
function first maps through all the blog posts in state.allPosts
array. If it finds the blog post that matches the id
provided in the payload
, it creates a new object with the same properties as the original post, but with updated title
and content
properties, using the values provided in the payload
.
If it doesn’t find the post with the matching id
, it returns the original post object.
After all the posts have been mapped through, the state.allPosts
array is updated to the new array of updatedPosts
.
Launch the EditPost.tsx
file and update it with the following additions:
import { useState } from "react";
- import { IPost } from "../../typings";
import PostInputForm from "./PostInputForm";
+import { useAppDispatch, useAppSelector } from "../../app/hooks";
+import { switchToAllPostsComponent } from "../navigations/navigationSlice";
+import { updateBlogPost, selectAllPosts } from "./postSlice";
const EditPost = () => {
+ const dispatch = useAppDispatch();
+ const allPost = useAppSelector(selectAllPosts);
+ const [title, setTitle] = useState(allPost.currentPost.title);
+ const [content, setContent] = useState(allPost.currentPost.content);
- const [title, setTitle] = useState("");
- const [content, setContent] = useState("");
+ const modifyPost = (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+ dispatch(updateBlogPost({ id: allPost.currentPost.id, title, content }));
+ dispatch(switchToAllPostsComponent());
+ };
- const updatePost = (e: React.FormEvent<HTMLFormElement>) => {
- e.preventDefault();
- const post: IPost = {
- id: Date.now(),
- title,
- content,
- voteCount: 0,
- isFavorite: false,
- };
- console.log(post);
- };
return (
<>
<PostInputForm
- submitPost={updatePost}
submitPost={modifyPost}
title={title}
content={content}
setTitle={setTitle}
setContent={setContent}
/>
</>
);
};
export default EditPost;
We created a modifyPost
function to handle the form submission when a user modifies a post. Firstly, it dispatches updateBlogPost
action with the updated post title
and content
. Then, it dispatches switchToAllPostsComponent
action to switch to the AllPosts
component.
Let’s modify the BlogCard
component to enable switching to the EditPost
component and rendering the PostInputForm
component with the post data when a user clicks on the pencil icon on a post.
Open the BlogCard.tsx
file and update it with the following changes:
import { IPost } from "../../typings";
import {
downvotePost,
upvotePost,
toggleFavorite,
setCurrentPost,
} from "./postSlice";
import { useAppDispatch } from "../../app/hooks";
import {
switchToViewPostComponent,
+ switchToEditPostComponent,
} from "../navigations/navigationSlice";
const BlogCard = (props: { posts: IPost[] }) => {
const dispatch = useAppDispatch();
const viewDetails = (post: IPost) => {
dispatch(setCurrentPost(post));
dispatch(switchToViewPostComponent());
};
+ const editBlog = (post: IPost) => {
+ dispatch(setCurrentPost(post));
+ dispatch(switchToEditPostComponent());
+ };
return (
<>
{props.posts.map((post) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon"
+ onClick={() => editBlog(post)}
>
✎
</span>
<h1 className="delete-icon">x</h1>
</div>
<section
className="card-body"
onClick={() => {
viewDetails(post);
}}
>
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
<section onClick={() => dispatch(toggleFavorite(post.id))}>
<span className="favorite">{post.isFavorite ? "♥" : "♡"}</span>
</section>
<section className="rating-section">
<span
className="upvote"
onClick={() => dispatch(upvotePost(post.id))}
>
⇧
</span>
<span className="vote-counter">{post.voteCount}</span>
<span
className="downvote"
onClick={() => dispatch(downvotePost(post.id))}
>
⇩
</span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
We created a new function called editBlog
that takes a post
parameter. Inside the function, two actions are dispatched. The first dispatched action is setCurrentPost
, which sets the currentPost object to be the post
passed as an argument. The second dispatched action is switchToEditPostComponent
, which switches the current component to the EditPost
component. The editBlog
function is invoked once a user clicks on the pencil icon.
Deleting a post from the state is a straightforward process. All we need to do is obtain its id
and dispatch an action to delete it.
Open the postSlice.ts
file and update it with the following additions:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
currentPost: IPost;
}
const defaultCurrentPost: IPost = {
id: 0,
title: "",
content: "",
voteCount: 0,
isFavorite: false,
};
const initialState: IAllPosts = {
allPosts: [],
currentPost: defaultCurrentPost,
};
export const postSlice = createSlice({
name: "all-posts",
initialState: initialState,
reducers: {
saveBlogPost: (state, actions: { payload: IPost }) => {
state.allPosts.push(actions.payload);
},
upvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount + 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
downvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount - 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
toggleFavorite: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, isFavorite: !post.isFavorite };
}
return post;
});
state.allPosts = updatedPosts;
},
setCurrentPost: (state, action: { payload: IPost }) => {
state.currentPost = action.payload;
return state;
},
updatePost: (
state,
actions: { payload: { id: number; content: string; title: string } }
) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload.id) {
return {
...post,
title: actions.payload.title,
content: actions.payload.content,
};
}
return post;
});
state.allPosts = updatedPosts;
},
updateBlogPost: (
state,
actions: { payload: { id: number; content: string; title: string } }
) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload.id) {
return {
...post,
title: actions.payload.title,
content: actions.payload.content,
};
}
return post;
});
state.allPosts = updatedPosts;
},
+ deletePost: (state, actions: { payload: number }) => {
+ const updatedPosts = state.allPosts.filter((post) => {
+ return post.id !== actions.payload;
+ });
+ state.allPosts = updatedPosts;
+ },
},
});
export const selectAllPosts = (state: RootState) => state.posts;
export const {
saveBlogPost,
upvotePost,
downvotePost,
toggleFavorite,
setCurrentPost,
updateBlogPost,
+ deletePost,
} = postSlice.actions;
export default postSlice.reducer;
We defined a reducer function called deletePost
that takes the state
and actions
parameters. The payload
property of the actions
parameter is expected to contain the id
of the post to be deleted. Inside the function, a new array of updated posts is created by filtering the allPosts
array in the state
. The filter
method returns a new array with all elements that meet a certain condition defined in the callback function. In this case, the condition is that the id
of the post should not be equal to the payload
value.
Finally, the allPosts
property in the state
is updated to the new array of updated posts, effectively removing the post with the matching id
from the list.
To enable the deletion of a post, the reducer function must be dispatched when the delete icon (x
) is clicked. To achieve this, add the following updates to the BlogCard
component:
import { IPost } from "../../typings";
import {
downvotePost,
upvotePost,
toggleFavorite,
setCurrentPost,
+ deletePost,
} from "./postSlice";
import { useAppDispatch } from "../../app/hooks";
import {
switchToViewPostComponent,
switchToEditPostComponent,
} from "../navigations/navigationSlice";
const BlogCard = (props: { posts: IPost[] }) => {
const dispatch = useAppDispatch();
const viewDetails = (post: IPost) => {
dispatch(setCurrentPost(post));
dispatch(switchToViewPostComponent());
};
const editBlog = (post: IPost) => {
dispatch(setCurrentPost(post));
dispatch(switchToEditPostComponent());
};
return (
<>
{props.posts.map((post) => {
return (
<section className="card" key={post.id}>
<div className="card-header">
<span className="edit-icon" onClick={() => editBlog(post)}>
✎
</span>
- <h1 className="delete-icon">x</h1>
+ <h1 className="delete-icon"onClick={()=>dispatch(deletePost(post.id))}>x</h1>
</div>
<section
className="card-body"
onClick={() => {
viewDetails(post);
}}
>
<h1 className="card-title">
{post.title.length > 150
? post.title.slice(0, 150) + "..."
: post.title}
</h1>
<p className="card-summary">
{post.content.length > 120
? post.content.slice(0, 120) + "..."
: post.content}
</p>
</section>
<section className="card-bottom">
<section onClick={() => dispatch(toggleFavorite(post.id))}>
<span className="favorite">{post.isFavorite ? "♥" : "♡"}</span>
</section>
<section className="rating-section">
<span
className="upvote"
onClick={() => dispatch(upvotePost(post.id))}
>
⇧
</span>
<span className="vote-counter">{post.voteCount}</span>
<span
className="downvote"
onClick={() => dispatch(downvotePost(post.id))}
>
⇩
</span>
</section>
</section>
</section>
);
})}
</>
);
};
export default BlogCard;
To fetch all the favorite posts in the app, we will create a selector function that will be used to retrieve all the posts marked as favorite.
Add the following updates to the postSlice.tsx
file:
import { createSlice } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";
import { IPost } from "../../typings";
interface IAllPosts {
allPosts: IPost[];
currentPost: IPost;
}
const defaultCurrentPost: IPost = {
id: 0,
title: "",
content: "",
voteCount: 0,
isFavorite: false,
};
const initialState: IAllPosts = {
allPosts: [],
currentPost: defaultCurrentPost,
};
export const postSlice = createSlice({
name: "all-posts",
initialState: initialState,
reducers: {
saveBlogPost: (state, actions: { payload: IPost }) => {
state.allPosts.push(actions.payload);
},
upvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount + 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
downvotePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, voteCount: post.voteCount - 1 };
}
return post;
});
state.allPosts = updatedPosts;
},
toggleFavorite: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload) {
return { ...post, isFavorite: !post.isFavorite };
}
return post;
});
state.allPosts = updatedPosts;
},
setCurrentPost: (state, action: { payload: IPost }) => {
state.currentPost = action.payload;
return state;
},
updatePost: (
state,
actions: { payload: { id: number; content: string; title: string } }
) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload.id) {
return {
...post,
title: actions.payload.title,
content: actions.payload.content,
};
}
return post;
});
state.allPosts = updatedPosts;
},
updateBlogPost: (
state,
actions: { payload: { id: number; content: string; title: string } }
) => {
const updatedPosts = state.allPosts.map((post) => {
if (post.id === actions.payload.id) {
return {
...post,
title: actions.payload.title,
content: actions.payload.content,
};
}
return post;
});
state.allPosts = updatedPosts;
},
deletePost: (state, actions: { payload: number }) => {
const updatedPosts = state.allPosts.filter((post) => {
return post.id !== actions.payload;
});
state.allPosts = updatedPosts;
},
},
});
export const selectAllPosts = (state: RootState) => state.posts;
+export const selectAllFavoritePosts = (state: RootState) =>
+ state.posts.allPosts.filter((post) => {
+ return post.isFavorite === true;
+ });
export const {
saveBlogPost,
upvotePost,
downvotePost,
toggleFavorite,
setCurrentPost,
updateBlogPost,
deletePost,
} = postSlice.actions;
export default postSlice.reducer;
We defined a selector function named selectAllFavoritePosts
that takes the state as input and returns an array of all the posts in the allPosts
array that have their isFavorite
property set to true
.
The filter
function is used to iterate over each element in the allPosts
array and returns a new array of only the posts that pass the condition of having isFavorite
set to true
. The returned array contains only the favorite posts.
Make the following changes to the FavoritePosts
component:
import BlogCard from "./BlogCard";
import NoFavoritePosts from "./NoFavoritePosts";
+import { selectAllFavoritePosts } from "./postSlice";
+import { useAppSelector } from "../../app/hooks";
const FavoritePosts = () => {
- const favoritePosts: any[] = [];
+ const favoritePosts = useAppSelector(selectAllFavoritePosts);
if (!favoritePosts.length) {
return <NoFavoritePosts />;
}
return (
<>
<BlogCard posts={favoritePosts} />
</>
);
};
export default FavoritePosts;
Now, whenever the FavoritePosts
component is rendered, the selectAllFavoritePosts
function will be invoked to fetch all the favorite posts, which will then be displayed on the screen.
Next, we’ll update the NoFavoritePosts
so that whenever a user clicks on the “Add a post to your favorites” button, the AllPosts
component will be rendered:
+import { useAppDispatch } from "../../app/hooks";
+import { switchToAllPostsComponent } from "../navigations/navigationSlice";
const NoFavoritePosts = () => {
+ const dispatch = useAppDispatch();
return (
<>
<section className="no-posts-card">
<h1>You haven't marked any blog posts as favorites.</h1>
- <button>Add a post to your favorites</button>
+ <button onClick={() => dispatch(switchToAllPostsComponent())}>
+ Add a post to your favorites
+ </button>
</section>
</>
);
};
export default NoFavoritePosts;
In this tutorial, we successfully created the front-end part of a blog application using React and the latest version of Redux Toolkit. The app includs various features such as navigation, post creation, editing and deletion, upvoting and downvoting, and adding and retrieving posts to favorites.
Moreover, we used the createSlice
function to write reducer logics that appeared to mutate the state but without actually mutating it. The Immer
library was used by the createSlice
function to identify changes to a “draft state” and generate a new immutable state based on those changes.
So far, our application has exclusively used synchronous logic. When actions are dispatched, the store runs the reducers, calculates the new state and the dispatch function completes. Nevertheless, it is recommended that you familiarize yourself with Middleware and Side Effects in Redux because the Redux store is unaware of any asynchronous logic. Any asynchronous actions, such as retrieving data from an API or saving a file, must occur outside the store. By learning more about side effects in Redux, you will learn how to write asynchronous logic with Thunk.