Complete Redux Toolkit in Next Js with Typescript
Introduction
In this tutorial, we will learn how to integrate Redux and LocalStorage into a Next.js 14 application using TypeScript. We will start by defining key Redux terminologies, and then we will build practical examples to solidify our understanding.
Setting Up Next.js 14 with TypeScript and Install redux toolkit
First, let's set up a new Next.js project with TypeScript.
npx create-next-app@latest nextjs-redux-ts --typescript
cd nextjs-redux-ts
npm install @reduxjs/toolkit react-redux
Defining Redux Terminologies
-
Slice: A slice is a collection of Redux reducer logic and actions for a single feature of the application. It is created using the createSlice function from Redux Toolkit.
-
Action: Actions are plain objects with a type property that indicates the type of action being performed. They can also contain additional data.
-
Dispatch: The dispatch function is used to send actions to the Redux store.
-
Reducers: Reducers are functions that take the current state and an action as arguments and return a new state.
-
Provider: The Provider component makes the Redux store available to any nested components that need to access the Redux state.
Implementing Redux with a Counter Example
Setting Up the Store:
Create a store.ts file in the src/store directory.
// store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
Creating a Counter Slice:
Create a counterSlice.ts file in the store/slices directory.
// store/slices/counterSlice.ts
import { createSlice } from "@reduxjs/toolkit";
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
},
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
Integrating Provider in layout.tsx:
Create a provider in the Providers folder and the Integrate it into the layout
// providers
import { Provider } from "react-redux";
import store from "../store/store";
function Providers({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
export default Providers;
Creating the Counter Component:
Create a Counter component in the src/components directory.
// src/components/Counter.tsx
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../store/store";
import { increment, decrement } from "../store/counterSlice";
const Counter: React.FC = () => {
const dispatch = useDispatch();
const count = useSelector((state: RootState) => state.counter.value);
return (
<div>
<h1>Counter: {count}</h1>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
</div>
);
};
export default Counter;
Define Typed Hooks
While it's possible to import the RootState and AppDispatch types into each component, it's better to create typed versions of the useDispatch and useSelector hooks for usage in your application. This is important for a couple reasons:
For useSelector, it saves you the need to type (state: RootState) every time For useDispatch, the default Dispatch type does not know about thunks. In order to correctly dispatch thunks, you need to use the specific customized AppDispatch type from the store that includes the thunk middleware types, and use that with useDispatch. Adding a pre-typed useDispatch hook keeps you from forgetting to import AppDispatch where it's needed.
import { useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
Using the Counter Component:
Add the Counter component to the page.tsx page.
// src/pages/index.tsx
import Counter from "../components/Counter";
const Home = () => {
return (
<div>
<h1>Redux Counter Example</h1>
<Counter />
</div>
);
};
export default Home;
Implementing Redux with an Add to Cart Example
Setting Up Cart Slice:
Create a cartSlice.ts file in the /store directory.
// src/store/cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CartItem {
id: number;
name: string;
price: number;
image: string;
}
interface CartState {
cartItems: CartItem[];
}
// Safely retrieve cart items from localStorage
const getInitialCartItems = (): CartItem[] => {
try {
const storedCart = localStorage.getItem("cart");
if (storedCart) {
return JSON.parse(storedCart);
}
} catch (error) {
console.error("Failed to parse cart items from localStorage", error);
}
return [];
};
const initialState: CartState = {
cartItems: getInitialCartItems(),
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addProductToCart: (state, action: PayloadAction<CartItem>) => {
state.cartItems.push(action.payload);
},
removeProductFromCart: (state, action: PayloadAction<number>) => {
state.cartItems = state.cartItems.filter(
(item) => item.id !== action.payload
);
},
},
});
export const { addProductToCart, removeProductFromCart } = cartSlice.actions;
export default cartSlice.reducer;
Updating the Store:
Update store.ts to include the product reducer.
// src/store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterSlice from "./slices/counterSlice";
import cartSlice from "./slices/cartSlice";
const store = configureStore({
reducer: {
counter: counterSlice,
cart: cartSlice,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
Adding Item to CART in the Product Component
"use client";
import Image from "next/image";
import React, { useEffect, useState } from "react";
import { Button } from "./ui/button";
import { ShoppingBag } from "lucide-react";
import { IProduct, type Product } from "@/types/types";
import { useAppDispatch, useAppSelector } from "@/store/hooks/hooks";
import {
addProductToCart,
removeProductFromCart,
} from "@/store/slices/cartSlice";
import toast from "react-hot-toast";
export default function Product({ product }: { product: IProduct }) {
const [existing, setExisting] = useState(false);
const cartItems = useAppSelector((state) => state.cart.cartItems);
useEffect(() => {
// Check if the product already exists in the cart
const isExisting = cartItems.some((item) => item.id === product.id);
setExisting(isExisting);
}, [cartItems, product.id]);
const dispatch = useAppDispatch();
function handleAdd() {
const newCartItem = {
id: product.id,
image: product.images[0],
name: product.title,
price: product.price,
};
dispatch(addProductToCart(newCartItem));
localStorage.setItem("cart", JSON.stringify([...cartItems, newCartItem]));
toast.success("Item added successfully");
}
const handleRemove = (productId: number) => {
dispatch(removeProductFromCart(productId));
localStorage.setItem(
"cart",
JSON.stringify(cartItems.filter((product) => product.id !== productId))
);
};
return (
<div className="">
<Image
className="h-36 w-36 object-cover rounded"
src={product.images[0] ?? "/calcium.jpg"}
width={225}
height={225}
alt="calcium"
/>
<h3 className="font-semibold">{product.title}</h3>
<p className="font-semibold text-sm py-2">${product.price}</p>
{existing ? (
<Button
variant={"destructive"}
onClick={() => handleRemove(product.id)}
>
<ShoppingBag className="w-4 h-4 mr-2" />
<span> Remove from</span>
</Button>
) : (
<Button onClick={handleAdd}>
<ShoppingBag className="w-4 h-4 mr-2" />
<span> Add to Cart</span>
</Button>
)}
</div>
);
}
Fetching Cart Items and displaying them:
In the Cart Page
// app/cart
"use client";
import { Button } from "@/components/ui/button";
import { useAppDispatch, useAppSelector } from "@/store/hooks/hooks";
import { removeProductFromCart } from "@/store/slices/cartSlice";
import { Trash } from "lucide-react";
import Image from "next/image";
import React from "react";
export default function page() {
const cartItems = useAppSelector((state) => state.cart.cartItems);
console.log(cartItems);
const dispatch = useAppDispatch();
function handleRemove(id: number) {
dispatch(removeProductFromCart(id));
localStorage.setItem(
"cart",
JSON.stringify(cartItems.filter((product) => product.id !== id))
);
}
return (
<div className="bg-blue-50 py-8 px-8 min-h-screen ">
<div className="max-w-xl bg-white rounded-xl mx-auto min-h-96 p-8">
<h2>Shopping Cart ({cartItems.length})</h2>
{cartItems.length > 0 ? (
<div className="space-y-3 divide-y-4 divide-gray-100 ">
{cartItems.map((product) => {
return (
<div className="" key={product.id}>
<div className="py-3 px-4 flex items-center justify-between">
<Image
className="h-28 w-28 object-cover rounded"
src={product.image ?? "/calcium.jpg"}
width={225}
height={225}
alt="calcium"
/>
<h3 className="font-semibold">{product.name}</h3>
<p className="font-semibold text-sm py-2">
${product.price}
</p>
</div>
<Button
size={"sm"}
onClick={() => handleRemove(product.id)}
variant={"destructive"}
>
<Trash className="w-4 h-4 mr-2" />
Remove
</Button>
</div>
);
})}
</div>
) : (
<div className="text-center flex items-center justify-center h-full">
<h2>No Items in Cart</h2>
</div>
)}
</div>
</div>
);
}
Implementing a Multi-Step Form with React Hook Form
Installing Dependencies: Install React Hook Form.
npm i react-hook-form
Creating a Form Slice:
Create a formSlice.ts file in the src/store directory to manage the form state.
// src/store/formSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface FormState {
firstName: string;
lastName: string;
email: string;
phone: string;
step: number;
}
const initialState: FormState = {
firstName: "",
lastName: "",
email: "",
phone: "",
step: 1,
};
const formSlice = createSlice({
name: "form",
initialState,
reducers: {
updateForm: (state, action: PayloadAction<Partial<FormState>>) => {
return { ...state, ...action.payload };
},
nextStep: (state) => {
state.step += 1;
},
previousStep: (state) => {
state.step -= 1;
},
},
});
export const { updateForm, nextStep, previousStep } = formSlice.actions;
export default formSlice.reducer;
Updating the Store:
Include the form reducer in store.ts.
// src/store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
import productReducer from "./productSlice";
import formReducer from "./formSlice";
const store = configureStore({
reducer: {
counter: counterReducer,
products: productReducer,
form: formReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
Creating the Multi-Step Form Component:
Update the MultiStepForm component to use Redux for managing form state.
// src/components/MultiStepForm.tsx
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../store/store";
import { updateForm, nextStep, previousStep } from "../store/formSlice";
interface FormData {
firstName: string;
lastName: string;
email: string;
phone: string;
}
const MultiStepForm: React.FC = () => {
const dispatch = useDispatch();
const { firstName, lastName, email, phone, step } = useSelector(
(state: RootState) => state.form
);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
defaultValues: { firstName, lastName, email, phone },
});
const onSubmit: SubmitHandler<FormData> = (data) => {
dispatch(updateForm(data));
console.log(data);
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<div>
<div>
<label>First Name</label>
<input {...register("firstName", { required: true })} />
{errors.firstName && <span>This field is required</span>}
</div>
<div>
<label>Last Name</label>
<input {...register("lastName", { required: true })} />
{errors.lastName && <span>This field is required</span>}
</div>
<button
type="button"
onClick={() => {
handleSubmit(onSubmit)();
dispatch(nextStep());
}}
>
Next
</button>
</div>
)}
{step === 2 && (
<div>
<div>
<label>Email</label>
<input {...register("email", { required: true })} />
{errors.email && <span>This field is required</span>}
</div>
<div>
<label>Phone</label>
<input {...register("phone", { required: true })} />
{errors.phone && <span>This field is required</span>}
</div>
<button type="button" onClick={() => dispatch(previousStep())}>
Back
</button>
<button type="submit">Submit</button>
</div>
)}
</form>
</div>
);
};
export default MultiStepForm;
Using the Updated Multi-Step Form Component:
Add the updated MultiStepForm component to the index.tsx page.
// src/pages/index.tsx
import Counter from "../components/Counter";
import Products from "../components/Products";
import MultiStepForm from "../components/MultiStepForm";
const Home = () => {
return (
<div>
<h1>Redux Counter, Add to Cart, and Multi-Step Form Example</h1>
<Counter />
<Products />
<MultiStepForm />
</div>
);
};
export default Home;